Skip to content

add Zoi.Ecto: changeset bridge for Zoi parse errors#162

Open
dimitarvp wants to merge 6 commits into
phcurado:mainfrom
dimitarvp:main
Open

add Zoi.Ecto: changeset bridge for Zoi parse errors#162
dimitarvp wants to merge 6 commits into
phcurado:mainfrom
dimitarvp:main

Conversation

@dimitarvp

@dimitarvp dimitarvp commented Apr 13, 2026

Copy link
Copy Markdown

🤖⚠️ ROBOT DISCLAIMER 🤖⚠️

This entire PR, and the texts, are heavily influenced and coded by Claude Code -- with a lot of input and iteration from me. I have a pretty good idea what extra functionality I need from Zoi so I can daily-drive it in my work and recommend it to other people and companies, and I have made sure to try and get the maximum Ecto compatibility as the utmost priority.


Adds Zoi.Ecto.changeset/2 and errors_to_changeset/1 to convert Zoi parse errors into Ecto-native changesets. Error opts match Ecto's validation:/kind: format with template-style messages. Nested maps produce nested changesets, arrays produce padded lists. Ecto is an optional dependency so if the using project does not have it in its mix.exs then this code will not be compiled into it at all.

What does this add to Zoi?

A lightweight bridge so that Zoi parse errors can be consumed by anything in the Phoenix/Ecto ecosystem that expects %Ecto.Changeset{} -- Absinthe error rendering, LiveView forms, traverse_errors/2, etc.

Two public functions:

  • Zoi.Ecto.changeset(schema, input) -- parses with Zoi via Zoi.Context, returns a changeset (valid or invalid). On failure, successfully parsed flat fields and nested maps are preserved in .data.
  • Zoi.Ecto.errors_to_changeset(errors) -- converts [%Zoi.Error{}] into a changeset

Ecto error format mapping

Zoi error code Ecto validation: Ecto kind: Extra opts
:required :required --
:invalid_type :cast --
:invalid_format :format --
:invalid_enum_value :inclusion -- enum: [list]
:invalid_length :length :is count:
:greater_than :number or :length :greater_than or :min number: for numeric
:greater_than_or_equal_to :number or :length :greater_than_or_equal_to or :min number: for numeric
:less_than :number or :length :less_than or :max number: for numeric
:less_than_or_equal_to :number or :length :less_than_or_equal_to or :max number: for numeric
:unrecognized_key :unknown_field --
:custom (from refine) :custom --

Size codes (:number vs :length) are disambiguated via Zoi's error message templates -- string constraints end with "character(s)", array constraints end with "item(s)". Length errors use Ecto-native :min/:max/:is for kind:. Numeric errors include number: target_value matching Ecto's convention.

Error path routing

  • []:base (root-level refine errors)
  • [:field] → flat changeset error
  • [:address, :zip] → nested changeset in changes[:address]
  • [:items, 0, :name] → list of changesets in changes[:items]

Tests

E-commerce order scenario: nested address, array of line items with min/max, discriminated union for payment method, cross-field validation via refine. Covers every mapped code, every error path pattern, edge cases, Ecto compatibility, and partial parse data preservation.

Known limitations

These may be raised as separate issues and/or addressed in follow-up PRs:

  • Array positions without errors get empty valid changesets (no parsed data) because Zoi.Context.parsed drops errored array items without preserving their original index -- valid items shift position, making alignment impossible.
  • Enum enum: key is produced by splitting Zoi's comma-joined values string. Values containing ", " will split incorrectly.
  • Length errors lack type: key -- Ecto includes type: :string/:list, Zoi's error opts don't carry target type info.
  • Size disambiguation relies on error message template text ("character(s)"/"item(s)").

@dimitarvp

Copy link
Copy Markdown
Author

@phcurado Let's please not merge this PR until we have decided whether some gaps I want to tell you about will be addressed first, or will be left for future PRs. I pinged you on ElixirForum to let me know where do you want them discussed.

Point being: current Ecto compatibility is not 100%; there are some gaps. If Zoi fills them then we can get to 100%.

@phcurado

Copy link
Copy Markdown
Owner

sorry taking a bit of time but I addressed the problems you mentioned. I think I implemented all suggestions now, it's on the main branch if u want to check it out (not released yet). Only point I didnt work was the coerce because we already have helpers for that.

  Zoi.map(%{
    address: Zoi.map(%{zip: Zoi.string()})
  })
  |> Zoi.Schema.traverse(&Zoi.coerce/1)
  |> Zoi.coerce()

should already coerce everything, from basic types to more complex structures

Adds `Zoi.Ecto.changeset/2` and `errors_to_changeset/1` to convert
Zoi parse errors into Ecto-native changesets. Error opts match
Ecto's `validation:`/`kind:` format with template-style messages.
Nested maps produce nested changesets, arrays produce padded
lists. Ecto is an optional dependency.
`changeset/2` now uses `Zoi.Context` directly so that successfully
parsed flat fields and nested maps are preserved in `.data` on
failure. Array items still get empty changesets at non-errored
positions because `Context.parsed` drops errored array items
without preserving their index. Use `&fun/1` capture for
`Zoi.refine` in test schemas.
enum values are now a native list (phcurado#167), no more string
splitting. size validation uses opts[:type] (phcurado#170) instead of
template matching. valid array items carry parsed data from
Zoi.Context (phcurado#166) instead of empty changesets. updated known
limitations to reflect resolved items.
Ecto uses type: :list for array length errors, Zoi uses
type: :array. remap to match Ecto's convention so consumers
checking opts[:type] get the expected value.
full cross-reference of all Ecto validation types vs Zoi error
codes, with exact opts produced. separate table for Ecto
validations that have no Zoi equivalent and why.
@dimitarvp

Copy link
Copy Markdown
Author

Awesome work @phcurado, thank you. I have rebased and me & Claude did the following:

  • Enum values -- removed the String.split(", ") workaround. values: is now a normal Elixir list passed through directly as enum: for full Ecto compatibility.
  • Size validation type -- replaced template-matching i.e. String.ends_with?("character(s)")) with opts[:type] lookup and branching. :string and :array resolve to Ecto's :length, everything else is a :number. No more parsing of the Zoi message.
  • Discriminated union variant -- discriminator: in error opts is now available for any downstream consumers. No changes needed on our side for this PR but really nice to have, thanks.
  • Array partial data -- the integer-keyed map format is great. changeset/2 now populates valid array items with their actual parsed data instead of empty changesets. Added a test with a mixed pass/fail array (3 items: valid, invalid, valid) verifying that each position has the right data (or an error changeset). errors_to_changeset/1 (errors-only path) still pads with empty changesets since it doesn't receive parse data -- documented in "Known Limitations" in the @moduledoc.

RE: the coerce, got it, it's not bothering me. Zoi.Schema.traverse(&Zoi.coerce/1) |> Zoi.coerce() works fine.

Also added a Markdown table with what exists and does not exist in Zoi that Ecto supports so the users of the library have a clear picture.

two variants sharing an email field with different constraints
prove that discriminator in error opts identifies which variant
produced the error. demonstrates the value of phcurado's phcurado#168.
@dimitarvp

Copy link
Copy Markdown
Author

Also added a test for :discriminator, just in case. 😃

@phcurado

Copy link
Copy Markdown
Owner

hi @dimitarvp thanks btw for helping so much here, I know it takes a lot of time and effort so I appreciate it!

I looked at the PR and seems in a decent shape, do you mind if I do some changes and play around with your PR (I can branch out your PR if you dont want me to change)? I want to see if I can simplify/improve somethings. I know you mentioned you used AI and I think I can improve this part specifically since I know the lib pretty well. I think Claude exaggerated in some guards (I might be mistaken I need to properly test it out) but I think I can improve it.

Btw regarding the core logic, what do you think in general, do you think it will help you and the problems you face? Or do you think you might need more features (such as embeds), etc? Brainstorming here so I can get some ideas for future support with ecto and if I finally make the decision to add here or we create another lib

@dimitarvp

dimitarvp commented Apr 16, 2026

Copy link
Copy Markdown
Author

Hey @phcurado.

Of course, feel free to massage this -- you know your codebase best. I have used Claude indeed, though I am quite rigorous and do architecture and design much more because as we know LLMs are very happy to take shortcuts or outright skip work. So please, play with it and modify the PR.

And yes this PR absolutely will help me in my work already as it is. No need for embedded schemas just yet. By itself it's already super valuable and if it was officially in I could start integrating it tomorrow.

HOWEVER. I do think more integration -- like the embedded schemas -- can be added with time, which indeed makes it potentially a better idea to spin off all this into a zoi_ecto library. The original problem I started off with still exists: why would we have both a Zoi schema and an embedded schema for the people who need the latter due to deep ecosystem investment and a lot of existing code already using them? Using Zoi as the authoritative source of truth and having everything else derived from it is still the right thing to do (so people can continue using embedded schemas). It's just that replacing the embedded schemas in my work projects with Zoi is still low priority and I have much bigger fish to fry before that.

The logistics of what goes into which project don't trouble me but you as the author should take all this into account. I am fine using the code of this PR either way: as part of core Zoi or as a separate library. My role was to explain the need for this so you know what problems are people like me solving with Zoi, plus give you a code draft. From then on it's up to you.

If you allow me one vague idea: you could look into how does Ash integrate with Ecto, especially forms. Might give you an idea or two, I think.

So TL;DR of course, feel free to modify this until you are satisfied with it. My apologies for the many coding lines added -- I worked with Claude for hours to make sure the test coverage is good and that's really like 75% of all code added here, as you can see. I'll monitor here periodically out of curiosity.

Let me know if that helps or if you think anything else deserves discussion!

@phcurado

Copy link
Copy Markdown
Owner

I might be wrong, could not improve much the current implementation, it's consistent and ecto way of working is what makes it more complex to convert the Zoi parsing to changeset format.

After analyzing I'm willing to take this to a separated package, I think it will be a better direction. Let me know if you would like to be the owner of the package. If not I can take the lead and I will do it, although will be highly inspired on all the good work you have done already.

@dimitarvp

Copy link
Copy Markdown
Author

Yes, I prefer authoring it and maintaining it. If you're okay with it, I'd like to give you maintainer access as well.

@phcurado

Copy link
Copy Markdown
Owner

that would be perfect, happy to contribute aswell

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants