Repository: elixir-ecto/ecto
Pull request #4382 (2024-02-22 19:36:03)

Bubble up custom cast errors of the inner type for `{:map, type}` and `{:array, type}`
Take the following custom type as an example:
```elixir
  defmodule CustomWithCastError do
    use Ecto.Type

    def type,      do: :any
    def load(_),   do: {:ok, :load}
    def dump(_),   do: {:ok, :dump}
    def cast(_),   do: {:error, foo: :bar}
  end
```
When we attempt to cast this type wrapped in an `array` or a `map`, the current behaviour in Ecto is that we get:
```elixir
    assert cast({:array, CustomWithCastError}, ["foo", "bar", "baz"]) == :error

    assert cast({:map, CustomWithCastError}, %{"x" => "foo", "y" => "bar"}) == :error
```
Where as you can see, the custom errors returned by our custom type are lost. That, and we do not know what item of the array or the map made the cast fail. This PR aims to improve on both of these things.

With the changes in this PR, the behaviour above changes to:
```elixir
    assert cast({:array, CustomWithCastError}, ["foo", "bar", "baz"]) == {:error, [index: 0, foo: :bar]}

    assert cast({:map, CustomWithCastError}, %{"x" => "foo", "y" => "bar"}) == {:error, [key: "x", foo: :bar]}
```
So we do return the custom error, and we add a reference that indicates what item made the cast fail. We include an `index` integer for the array, and the `key` for the map.


Patch:
diff --git a/Earthfile b/Earthfile
index d626dc68..dbd903ba 100644
--- a/Earthfile
+++ b/Earthfile
@@ -2,12 +2,13 @@ VERSION 0.6
 
 all:
     BUILD \
-        --build-arg ELIXIR_BASE=1.15.6-erlang-25.3.2.6-alpine-3.16.7 \
-        --build-arg ELIXIR_BASE=1.15.6-erlang-24.3.4.14-alpine-3.16.7 \
+        --build-arg ELIXIR_BASE=1.15.6-erlang-25.3.2.6-alpine-3.18.4 \
+        --build-arg ELIXIR_BASE=1.15.6-erlang-24.3.4.14-alpine-3.18.4 \
         +integration-test
 
 integration-test-base:
-    ARG ELIXIR_BASE=1.15.6-erlang-25.3.2.6-alpine-3.16.7
+    ARG ELIXIR_BASE=1.15.6-erlang-25.3.2.6-alpine-3.18.4
+    ARG TARGETARCH 
     FROM hexpm/elixir:$ELIXIR_BASE
     RUN apk add --no-progress --update git build-base
     RUN mix local.rebar --force
@@ -17,11 +18,11 @@ integration-test-base:
     RUN apk add --no-progress --update docker docker-compose git postgresql-client mysql-client
 
     RUN apk add --no-cache curl gnupg --virtual .build-dependencies -- && \
-        curl -O https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.2.1-1_amd64.apk && \
-        curl -O https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.2.1-1_amd64.apk && \
-        echo y | apk add --allow-untrusted msodbcsql17_17.5.2.1-1_amd64.apk mssql-tools_17.5.2.1-1_amd64.apk && \
+        curl -O https://download.microsoft.com/download/3/5/5/355d7943-a338-41a7-858d-53b259ea33f5/msodbcsql18_18.3.2.1-1_${TARGETARCH}.apk && \
+        curl -O https://download.microsoft.com/download/3/5/5/355d7943-a338-41a7-858d-53b259ea33f5/mssql-tools18_18.3.1.1-1_${TARGETARCH}.apk && \
+        echo y | apk add --allow-untrusted msodbcsql18_18.3.2.1-1_${TARGETARCH}.apk mssql-tools18_18.3.1.1-1_${TARGETARCH}.apk && \
         apk del .build-dependencies && rm -f msodbcsql*.sig mssql-tools*.apk
-    ENV PATH="/opt/mssql-tools/bin:${PATH}"
+    ENV PATH="/opt/mssql-tools18/bin:${PATH}"
 
     GIT CLONE https://github.com/elixir-ecto/ecto_sql.git /src/ecto_sql
     WORKDIR /src/ecto_sql
@@ -44,7 +45,7 @@ integration-test:
     ARG MYSQL_IMG="mysql:5.7"
 
     # then run the tests
-    WITH DOCKER --pull "$PG_IMG" --pull "$MCR_IMG" --pull "$MYSQL_IMG"
+    WITH DOCKER --pull "$PG_IMG" --pull "$MCR_IMG" --pull "$MYSQL_IMG" --platform linux/amd64
         RUN set -e; \
             timeout=$(expr $(date +%s) + 60); \
 
@@ -54,7 +55,7 @@ integration-test:
             docker run --name mysql --network=host -d -e MYSQL_ROOT_PASSWORD=root "$MYSQL_IMG"; \
 
             # wait for mssql to start
-            while ! sqlcmd -S tcp:127.0.0.1,1433 -U sa -P 'some!Password' -Q "SELECT 1" >/dev/null 2>&1; do \
+            while ! sqlcmd -C -S tcp:127.0.0.1,1433 -U sa -P 'some!Password' -Q "SELECT 1" >/dev/null 2>&1; do \
                 test "$(date +%s)" -le "$timeout" || (echo "timed out waiting for mysql"; exit 1); \
                 echo "waiting for mssql"; \
                 sleep 1; \
diff --git a/lib/ecto.ex b/lib/ecto.ex
index 54b902ad..87e046f3 100644
--- a/lib/ecto.ex
+++ b/lib/ecto.ex
@@ -697,7 +697,9 @@ defmodule Ecto do
 
   The third argument `format` is the format the data has been dumped as. For
   example, databases may dump embedded to `:json`, this function allows such
-  dumped data to be put back into the schemas.
+  dumped data to be put back into the schemas. If custom types are used,
+  Ecto will invoke the `c:Ecto.Type.embed_as/1` callback to decide if the data
+  should be loaded using `cast` or `load`.
 
   Fields that are not present in the schema (or `types` map) are ignored.
   If any of the values has invalid type, an error is raised.
diff --git a/lib/ecto/query/builder.ex b/lib/ecto/query/builder.ex
index e40b11ff..3ae1f523 100644
--- a/lib/ecto/query/builder.ex
+++ b/lib/ecto/query/builder.ex
@@ -777,7 +777,7 @@ defmodule Ecto.Query.Builder do
     do: do_literal(value, expected, quoted_type(value, vars))
 
   defp do_literal(value, _, current) when current in @always_tagged,
-    do: {:%, [], [Ecto.Query.Tagged, {:%{}, [], [value: value, type: current]}]}
+    do: {:%, [], [Ecto.Query.Tagged, {:%{}, [], [value: value, type: normalize_type(value, current)]}]}
   defp do_literal(value, :any, _current),
     do: value
   defp do_literal(value, expected, expected),
@@ -1228,6 +1228,9 @@ defmodule Ecto.Query.Builder do
   defp get_env({env, _}), do: env
   defp get_env(env), do: env
 
+  defp normalize_type(value, :binary),
+    do: quote(do: is_binary(unquote(value)) && :binary || :bitstring)
+
   @doc """
   Raises a query building error.
   """
diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex
index 50be5a13..151bbb3d 100644
--- a/lib/ecto/schema.ex
+++ b/lib/ecto/schema.ex
@@ -256,6 +256,7 @@ defmodule Ecto.Schema do
   `:boolean`              | `boolean`               | true, false
   `:string`               | UTF-8 encoded `string`  | "hello"
   `:binary`               | `binary`                | `<<int, int, int, ...>>`
+  `:bitstring`            | `bitstring`             | `<<_::size>>
   `{:array, inner_type}`  | `list`                  | `[value, value, value, ...]`
   `:map`                  | `map` |
   `{:map, inner_type}`    | `map` |
diff --git a/lib/ecto/type.ex b/lib/ecto/type.ex
index c1638420..a083a301 100644
--- a/lib/ecto/type.ex
+++ b/lib/ecto/type.ex
@@ -208,6 +208,7 @@ defmodule Ecto.Type do
           | :float
           | :boolean
           | :string
+          | :bitstring
           | :map
           | :binary
           | :decimal
@@ -227,7 +228,7 @@ defmodule Ecto.Type do
   @typep private_composite :: {:maybe, t} | {:in, t} | {:param, :any_datetime}
 
   @base ~w(
-    integer float decimal boolean string map binary id binary_id any
+    integer float decimal boolean string bitstring map binary id binary_id any
     utc_datetime naive_datetime date time
     utc_datetime_usec naive_datetime_usec time_usec
   )a
@@ -539,8 +540,10 @@ defmodule Ecto.Type do
     end
   end
 
-  def dump({:array, {_, _, _} = type}, value, dumper), do: array(value, type, dumper, false, [])
-  def dump({:array, type}, value, dumper), do: array(value, type, dumper, true, [])
+  def dump({:array, {_, _, _} = type}, value, dumper),
+    do: array_with_type(value, type, dumper, false, [])
+
+  def dump({:array, type}, value, dumper), do: array_with_type(value, type, dumper, true, [])
   def dump({:map, type}, value, dumper), do: map(value, type, dumper, false, %{})
 
   def dump(:any, value, _dumper), do: {:ok, value}
@@ -550,6 +553,7 @@ defmodule Ecto.Type do
   def dump(:map, value, _dumper), do: same_map(value)
   def dump(:string, value, _dumper), do: same_binary(value)
   def dump(:binary, value, _dumper), do: same_binary(value)
+  def dump(:bitstring, value, _dumper), do: same_bitstring(value)
   def dump(:id, value, _dumper), do: same_integer(value)
   def dump(:binary_id, value, _dumper), do: same_binary(value)
   def dump(:decimal, value, _dumper), do: same_decimal(value)
@@ -633,8 +637,10 @@ defmodule Ecto.Type do
     end
   end
 
-  def load({:array, {_, _, _} = type}, value, loader), do: array(value, type, loader, false, [])
-  def load({:array, type}, value, loader), do: array(value, type, loader, true, [])
+  def load({:array, {_, _, _} = type}, value, loader),
+    do: array_with_type(value, type, loader, false, [])
+
+  def load({:array, type}, value, loader), do: array_with_type(value, type, loader, true, [])
   def load({:map, type}, value, loader), do: map(value, type, loader, false, %{})
 
   def load(:any, value, _loader), do: {:ok, value}
@@ -644,6 +650,7 @@ defmodule Ecto.Type do
   def load(:map, value, _loader), do: same_map(value)
   def load(:string, value, _loader), do: same_binary(value)
   def load(:binary, value, _loader), do: same_binary(value)
+  def load(:bitstring, value, _loader), do: same_bitstring(value)
   def load(:id, value, _loader), do: same_integer(value)
   def load(:binary_id, value, _loader), do: same_binary(value)
   def load(:decimal, value, _loader), do: same_decimal(value)
@@ -814,6 +821,7 @@ defmodule Ecto.Type do
   defp cast_fun(:map), do: &cast_map/1
   defp cast_fun(:string), do: &cast_binary/1
   defp cast_fun(:binary), do: &cast_binary/1
+  defp cast_fun(:bitstring), do: &cast_bitstring/1
   defp cast_fun(:id), do: &cast_integer/1
   defp cast_fun(:binary_id), do: &cast_binary/1
   defp cast_fun(:any), do: &{:ok, &1}
@@ -831,12 +839,12 @@ defmodule Ecto.Type do
 
   defp cast_fun({:array, {:parameterized, _, _} = type}) do
     fun = cast_fun(type)
-    &array(&1, fun, false, [])
+    &array_with_index(&1, fun, false, 0, [])
   end
 
   defp cast_fun({:array, type}) do
     fun = cast_fun(type)
-    &array(&1, fun, true, [])
+    &array_with_index(&1, fun, true, 0, [])
   end
 
   defp cast_fun({:map, {:parameterized, _, _} = type}) do
@@ -897,6 +905,9 @@ defmodule Ecto.Type do
   defp cast_binary(term) when is_binary(term), do: {:ok, term}
   defp cast_binary(_), do: :error
 
+  defp cast_bitstring(term) when is_bitstring(term), do: {:ok, term}
+  defp cast_bitstring(_), do: :error
+
   defp cast_map(term) when is_map(term), do: {:ok, term}
   defp cast_map(_), do: :error
 
@@ -912,6 +923,9 @@ defmodule Ecto.Type do
   defp same_binary(term) when is_binary(term), do: {:ok, term}
   defp same_binary(_), do: :error
 
+  defp same_bitstring(term) when is_bitstring(term), do: {:ok, term}
+  defp same_bitstring(_), do: :error
+
   defp same_map(term) when is_map(term), do: {:ok, term}
   defp same_map(_), do: :error
 
@@ -1352,23 +1366,28 @@ defmodule Ecto.Type do
   defp of_base_type?(:date, value), do: Kernel.match?(%Date{}, value)
   defp of_base_type?(_, _), do: false
 
-  defp array([nil | t], fun, true, acc) do
-    array(t, fun, true, [nil | acc])
+  defp array_with_index([nil | t], fun, true, index, acc) do
+    array_with_index(t, fun, true, index + 1, [nil | acc])
   end
 
-  defp array([h | t], fun, skip_nil?, acc) do
+  defp array_with_index([h | t], fun, skip_nil?, index, acc) do
     case fun.(h) do
-      {:ok, h} -> array(t, fun, skip_nil?, [h | acc])
-      :error -> :error
-      {:error, _custom_errors} -> :error
+      {:ok, h} ->
+        array_with_index(t, fun, skip_nil?, index + 1, [h | acc])
+
+      :error ->
+        :error
+
+      {:error, custom_errors} ->
+        {:error, Keyword.update(custom_errors, :source, [index], &[index | &1])}
     end
   end
 
-  defp array([], _fun, _skip_nil?, acc) do
+  defp array_with_index([], _fun, _skip_nil?, _index, acc) do
     {:ok, Enum.reverse(acc)}
   end
 
-  defp array(_, _, _, _) do
+  defp array_with_index(_, _, _, _, _) do
     :error
   end
 
@@ -1386,9 +1405,14 @@ defmodule Ecto.Type do
 
   defp map_each([{key, value} | t], fun, skip_nil?, acc) do
     case fun.(value) do
-      {:ok, value} -> map_each(t, fun, skip_nil?, Map.put(acc, key, value))
-      :error -> :error
-      {:error, _custom_errors} -> :error
+      {:ok, value} ->
+        map_each(t, fun, skip_nil?, Map.put(acc, key, value))
+
+      :error ->
+        :error
+
+      {:error, custom_errors} ->
+        {:error, Keyword.update(custom_errors, :source, [key], &[key | &1])}
     end
   end
 
@@ -1396,22 +1420,22 @@ defmodule Ecto.Type do
     {:ok, acc}
   end
 
-  defp array([nil | t], type, fun, true, acc) do
-    array(t, type, fun, true, [nil | acc])
+  defp array_with_type([nil | t], type, fun, true, acc) do
+    array_with_type(t, type, fun, true, [nil | acc])
   end
 
-  defp array([h | t], type, fun, skip_nil?, acc) do
+  defp array_with_type([h | t], type, fun, skip_nil?, acc) do
     case fun.(type, h) do
-      {:ok, h} -> array(t, type, fun, skip_nil?, [h | acc])
+      {:ok, h} -> array_with_type(t, type, fun, skip_nil?, [h | acc])
       :error -> :error
     end
   end
 
-  defp array([], _type, _fun, _skip_nil?, acc) do
+  defp array_with_type([], _type, _fun, _skip_nil?, acc) do
     {:ok, Enum.reverse(acc)}
   end
 
-  defp array(_, _, _, _, _) do
+  defp array_with_type(_, _, _, _, _) do
     :error
   end
 


Test patch:
diff --git a/integration_test/cases/type.exs b/integration_test/cases/type.exs
index 057bfb3d..691ceaf6 100644
--- a/integration_test/cases/type.exs
+++ b/integration_test/cases/type.exs
@@ -1,7 +1,7 @@
 defmodule Ecto.Integration.TypeTest do
   use Ecto.Integration.Case, async: Application.compile_env(:ecto, :async_integration_tests, true)
 
-  alias Ecto.Integration.{Comment, Custom, Item, ItemColor, Order, Post, User, Tag, Usec}
+  alias Ecto.Integration.{Bitstring, Comment, Custom, Item, ItemColor, Order, Post, User, Tag, Usec}
   alias Ecto.Integration.TestRepo
   import Ecto.Query
 
@@ -67,6 +67,19 @@ defmodule Ecto.Integration.TypeTest do
     assert [^datetime] = TestRepo.all(from u in Usec, where: u.utc_datetime_usec == ^datetime, select: u.utc_datetime_usec)
   end
 
+  @tag :bitstring_type
+  test "bitstring type" do
+    bitstring = <<2::3>>
+
+    TestRepo.insert!(%Bitstring{bs: bitstring, bs_with_size: <<5::10>>})
+
+    # Bitstrings
+    assert [^bitstring] = TestRepo.all(from p in Bitstring, where: p.bs == ^bitstring, select: p.bs)
+    assert [^bitstring] = TestRepo.all(from p in Bitstring, where: p.bs == <<2::3>>, select: p.bs)
+
+    assert [<<42::6>>] = TestRepo.all(from p in Bitstring, limit: 1, select: p.bs_with_default)
+  end
+
   @tag :select_not
   test "primitive types boolean negate" do
     TestRepo.insert!(%Post{public: true})
diff --git a/integration_test/support/schemas.exs b/integration_test/support/schemas.exs
index 7b9464aa..01c50e7a 100644
--- a/integration_test/support/schemas.exs
+++ b/integration_test/support/schemas.exs
@@ -388,3 +388,19 @@ defmodule Ecto.Integration.ArrayLogging do
     timestamps()
   end
 end
+
+defmodule Ecto.Integration.Bitstring do
+  @moduledoc """
+  This module is used to test:
+
+    * Bitstring type
+
+  """
+  use Ecto.Integration.Schema
+
+  schema "bitstrings" do
+    field :bs,  :bitstring
+    field :bs_with_default, :bitstring
+    field :bs_with_size, :bitstring
+  end
+end
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index c183e171..67ec5145 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -432,7 +432,18 @@ defmodule Ecto.ChangesetTest do
     struct = %CustomErrorTest{}
 
     changeset = cast(struct, params, ~w(array_custom_error)a)
-    assert changeset.errors == [array_custom_error: {"is invalid", [type: {:array, Ecto.ChangesetTest.CustomError}, validation: :cast]}]
+
+    assert changeset.errors == [
+             array_custom_error:
+               {"custom error message",
+                [
+                  type: {:array, Ecto.ChangesetTest.CustomError},
+                  validation: :cast,
+                  reason: :foobar,
+                  source: [0]
+                ]}
+           ]
+
     refute changeset.valid?
   end
 
@@ -441,7 +452,18 @@ defmodule Ecto.ChangesetTest do
     struct = %CustomErrorTest{}
 
     changeset = cast(struct, params, ~w(map_custom_error)a)
-    assert changeset.errors == [map_custom_error: {"is invalid", [type: {:map, Ecto.ChangesetTest.CustomError}, validation: :cast]}]
+
+    assert changeset.errors == [
+      map_custom_error:
+        {"custom error message",
+         [
+           type: {:map, Ecto.ChangesetTest.CustomError},
+           validation: :cast,
+           reason: :foobar,
+           source: [:foo]
+         ]}
+    ]
+
     refute changeset.valid?
   end
 
diff --git a/test/ecto/query/builder_test.exs b/test/ecto/query/builder_test.exs
index 1f7aec99..b4b3f4cb 100644
--- a/test/ecto/query/builder_test.exs
+++ b/test/ecto/query/builder_test.exs
@@ -32,7 +32,7 @@ defmodule Ecto.Query.BuilderTest do
     assert {quote(do: ~s"123"), []} ==
            escape(quote do ~s"123" end, [], __ENV__)
 
-    assert {{:%, [], [Ecto.Query.Tagged, {:%{}, [], [value: {:<<>>, [], [0, 1, 2]}, type: :binary]}]}, []} ==
+    assert {{:%, [], [Ecto.Query.Tagged, {:%{}, [], [value: {:<<>>, [], [0, 1, 2]}, type: {:||, _, [{:&&, _, [{:is_binary, _, [{:<<>>, [], [0, 1, 2]}]}, :binary]}, :bitstring]}]}]}, []} =
            escape(quote do <<0, 1, 2>> end, [], __ENV__)
 
     assert {:some_atom, []} ==
diff --git a/test/ecto/type_test.exs b/test/ecto/type_test.exs
index 52444c47..0b18f665 100644
--- a/test/ecto/type_test.exs
+++ b/test/ecto/type_test.exs
@@ -20,6 +20,17 @@ defmodule Ecto.TypeTest do
     def cast(_),   do: {:ok, :cast}
   end
 
+  defmodule CustomWithCastError do
+    use Ecto.Type
+
+    def type,      do: :any
+    def load(_),   do: {:ok, :load}
+    def dump(_),   do: {:ok, :dump}
+    def cast("a"), do: {:ok, "a"}
+    def cast("b"), do: {:error, foo: :bar, value: "b"}
+    def cast("c"), do: {:error, foo: :bar, source: [:email], value: "c"}
+  end
+
   defmodule CustomParameterizedTypeWithFormat do
     use Ecto.ParameterizedType
 
@@ -88,10 +99,14 @@ defmodule Ecto.TypeTest do
     assert load(Custom, "foo") == {:ok, :load}
     assert dump(Custom, "foo") == {:ok, :dump}
     assert cast(Custom, "foo") == {:ok, :cast}
+    assert cast(CustomWithCastError, "a") == {:ok, "a"}
+    assert cast(CustomWithCastError, "b") == {:error, foo: :bar, value: "b"}
+    assert cast(CustomWithCastError, "c") == {:error, foo: :bar, source: [:email], value: "c"}
 
     assert load(Custom, nil) == {:ok, nil}
     assert dump(Custom, nil) == {:ok, nil}
     assert cast(Custom, nil) == {:ok, nil}
+    assert cast(CustomWithCastError, nil) == {:ok, nil}
 
     assert match?(Custom, :any)
     assert match?(:any, Custom)
@@ -145,17 +160,32 @@ defmodule Ecto.TypeTest do
     assert dump({:array, Custom}, ["foo"]) == {:ok, [:dump]}
     assert cast({:array, Custom}, ["foo"]) == {:ok, [:cast]}
 
+    assert cast({:array, CustomWithCastError}, ["b"]) ==
+             {:error, foo: :bar, value: "b", source: [0]}
+
+    assert cast({:array, CustomWithCastError}, ["a", "a", "a", "b"]) ==
+             {:error, foo: :bar, value: "b", source: [3]}
+
+    assert cast({:array, CustomWithCastError}, ["c"]) ==
+             {:error, foo: :bar, source: [0, :email], value: "c"}
+
+    assert cast({:array, CustomWithCastError}, ["a", "a", "c", "a"]) ==
+             {:error, foo: :bar, source: [2, :email], value: "c"}
+
     assert load({:array, Custom}, [nil]) == {:ok, [nil]}
     assert dump({:array, Custom}, [nil]) == {:ok, [nil]}
     assert cast({:array, Custom}, [nil]) == {:ok, [nil]}
+    assert cast({:array, CustomWithCastError}, [nil]) == {:ok, [nil]}
 
     assert load({:array, Custom}, nil) == {:ok, nil}
     assert dump({:array, Custom}, nil) == {:ok, nil}
     assert cast({:array, Custom}, nil) == {:ok, nil}
+    assert cast({:array, CustomWithCastError}, nil) == {:ok, nil}
 
     assert load({:array, Custom}, 1) == :error
     assert dump({:array, Custom}, 1) == :error
     assert cast({:array, Custom}, 1) == :error
+    assert cast({:array, CustomWithCastError}, 1) == :error
 
     assert load({:array, Custom}, [:unused], fn Custom, _ -> {:ok, :used} end) == {:ok, [:used]}
     assert dump({:array, Custom}, [:unused], fn Custom, _ -> {:ok, :used} end) == {:ok, [:used]}
@@ -166,17 +196,32 @@ defmodule Ecto.TypeTest do
     assert dump({:map, Custom}, %{"x" => "foo"}) == {:ok, %{"x" => :dump}}
     assert cast({:map, Custom}, %{"x" => "foo"}) == {:ok, %{"x" => :cast}}
 
+    assert cast({:map, CustomWithCastError}, %{"x" => "b"}) ==
+             {:error, foo: :bar, value: "b", source: ["x"]}
+
+    assert cast({:map, CustomWithCastError}, %{"x" => "a", "y" => "a", "z" => "a", "u" => "b"}) ==
+             {:error, foo: :bar, value: "b", source: ["u"]}
+
+    assert cast({:map, CustomWithCastError}, %{"x" => "c"}) ==
+             {:error, foo: :bar, source: ["x", :email], value: "c"}
+
+    assert cast({:map, CustomWithCastError}, %{"x" => "a", "y" => "a", "z" => "c", "u" => "a"}) ==
+             {:error, foo: :bar, source: ["z", :email], value: "c"}
+
     assert load({:map, Custom}, %{"x" => nil}) == {:ok, %{"x" => nil}}
     assert dump({:map, Custom}, %{"x" => nil}) == {:ok, %{"x" => nil}}
     assert cast({:map, Custom}, %{"x" => nil}) == {:ok, %{"x" => nil}}
+    assert cast({:map, CustomWithCastError}, %{"x" => nil}) == {:ok, %{"x" => nil}}
 
     assert load({:map, Custom}, nil) == {:ok, nil}
     assert dump({:map, Custom}, nil) == {:ok, nil}
     assert cast({:map, Custom}, nil) == {:ok, nil}
+    assert cast({:map, CustomWithCastError}, nil) == {:ok, nil}
 
     assert load({:map, Custom}, 1) == :error
     assert dump({:map, Custom}, 1) == :error
     assert cast({:map, Custom}, 1) == :error
+    assert cast({:map, CustomWithCastError}, 1) == :error
 
     assert load({:map, Custom}, %{"a" => :unused}, fn Custom, _ -> {:ok, :used} end) == {:ok, %{"a" => :used}}
     assert dump({:map, Custom}, %{"a" => :unused}, fn Custom, _ -> {:ok, :used} end) == {:ok, %{"a" => :used}}