JSV – 0.10.10 With a Pydantic flair

Hello, sorry to be spammy but I love working on JSV, my JSON Schema Validator library.

This is a small release with three important things:

  • Module-based schemas now need to export a json_schema/0 function instead of a schema/0 function. This makes more sense since we are starting to export json_schemas from other modules like Ecto schema modules. A generic schema/0 function is confusing in that case. Codebases using the old callback will still work, but a warning will be emitted.

    Example:

    defmodule ItemModule do
      def json_schema do
        %{type: :string}
      end
    end
    
    schema = %{type: :array, items: ItemModule} 
    
    data = ["foo", "bar"]
    

    Modules using JSV.defschema/1 will automatically export the new function as well as the old one, with a deprecation warning.

  • That defschema macro now supports passing the properties as a list directly.
    So instead of this:

    defmodule MyApp.UserSchema do
      use JSV.Schema
    
      defschema %{
        type: :object,
        properties: %{
          name: %{type: :string},
          age: %{type: :integer, default: 0}
        }
      }
    end
    

    You can do this:

    defmodule MyApp.UserSchema do
      use JSV.Schema
    
      defschema name: %{type: :string},
                age: %{type: :integer, default: 0}
    end
    

    And because use JSV.Schema imports the new schema definition helpers, it can be as short as this:

    defmodule MyApp.UserSchema do
      use JSV.Schema
    
      defschema name: string(),
                age: integer(default: 0)
    end
    
  • Finally, I’ve added the defschema/3 macro that works like defschema/1 but also defines a module:

    defmodule MyApp.Schemas do
      use JSV.Schema
    
      defschema User, 
        name: string(),
        age: integer(default: 0)
    
      defschema Admin,
        """
        With a schema description and @moduledoc
        """,
        user: User,
        privileges: array_of(string())
    end
    

    I think it’s nice to have this when defining some responses schemas directly in controllers or message queue consumers, à la Pydantic.

And that’s it :slight_smile: Thanks for reading!

[0.10.0] - 2025-07-10

:rocket: Features

  • Define and expect schema modules to export json_schema/0 instead of schema/0
  • Allow to call defschema with a list of properties
  • Added the defschema/3 macro to define schemas as submodules

:bug: Bug Fixes

  • Ensure defschema with keyword syntax supports module-based properties
9 Likes

That’s a commitment to quality

image

1 Like

Haha !

The truth is that I generate many tests from the official JSON schema test suite. All generated tests are in 3 versions:

  • one with raw schemas (string keys)
  • one with atom keys, most of the time using the JSV.Schema struct (only const is not in the struct so I use bare maps with atoms)
  • one using Decimal structs instead of numbers in the test values.

And as I use two suites, Draft 7 and 2020-12, you get a huge amount of tests!

3 Likes

heh.

at first, I tried to believe the tests were built by hand. then, I asked re: jsv:

got some useful hints from your work. thanks

3 Likes

Looking at you files it appears those are LLM generated right? I think it did a pretty good job (though it should have stopped at some point in that test guide, at the end it’s making things up). What did you use?

I tried to use Exdantic with JSV but we have an incompatibility. JSV expects raw data with binary keys while Exdantic seem to expect atom keys only:

Mix.install([
  {:jsv, "~> 0.10"},
  {:exdantic, "~> 0.0.2"}
])

defmodule UserSchema do
  use Exdantic, define_struct: true

  schema "User account information" do
    field :name, :string do
      required()
      min_length(2)
      description("User's full name")
    end

    field :email, :string do
      required()
      format(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
      description("Primary email address")
    end

    field :age, :integer do
      optional()
      gt(0)
      lt(150)
      description("User's age in years")
    end

    field :active, :boolean do
      default(true)
      description("Whether the account is active")
    end

    # Cross-field validation
    model_validator(:validate_adult_email)

    # Computed field derived from other fields
    computed_field(:display_name, :string, :generate_display_name)

    config do
      title("User Schema")
      strict(true)
    end
  end

  def validate_adult_email(input) do
    if input.age && input.age >= 18 && String.contains?(input.email, "example.com") do
      {:error, "Adult users cannot use example.com emails"}
    else
      {:ok, input}
    end
  end

  def generate_display_name(input) do
    display =
      if input.age do
        "#{input.name} (#{input.age})"
      else
        input.name
      end

    {:ok, display}
  end

  use JSV.Schema

  def json_schema do
    JSV.Schema.with_cast([__MODULE__, :from_jsv])
  end

  defcast from_jsv(data) do
    validate(data)
  end

  def format_error("from_jsv", [exdantic_error | _], _) do
    Exdantic.Error.format(exdantic_error)
  end
end

root = JSV.build!(UserSchema) |> dbg()
data = %{"name" => "alice", "email" => "foo@bar.com"}

JSV.validate!(data, root)


1 Like

I just used Claude Code. Yeah, Claude likes being “helpful”, whether hallucinating ideas or running git reset --hard after you ask it to not lose uncommitted work! (!!)

Thanks for looking at that integration doc with JSV. It was just brainstorms, really. Will look when time allows.

1 Like

Haven’t given exdantic (previously a fork of elixact) enough attention. It’s been on the shelf for weeks, awaiting integration.

Thanks for looking at this. You should be able to just disable strict mode.

It might be good for me to deprecate strict mode entirely from exdantic, as it’s not clear yet if it’s needed. Maybe? IDK.

$ elixir strictModeDeprecation/strict_vs_nonstrict_analysis.exs
=== EXDANTIC STRICT vs NON-STRICT BEHAVIOR ANALYSIS ===

STRICT - Valid atom keys:
  Input: %{name: "Alice", email: "alice@example.com", age: 30}
  ✅ SUCCESS: %StrictSchema{age: 30, email: "alice@example.com", name: "Alice"}

NON-STRICT - Valid atom keys:
  Input: %{name: "Alice", email: "alice@example.com", age: 30}
  ✅ SUCCESS: %NonStrictSchema{age: 30, email: "alice@example.com", name: "Alice"}

---
STRICT - Valid string keys:
  Input: %{"age" => 25, "email" => "bob@example.com", "name" => "Bob"}
  ❌ ERROR: [%Exdantic.Error{path: [], code: :additional_properties, message: "unknown fields: [\"age\", \"email\", \"name\"]"}]

NON-STRICT - Valid string keys:
  Input: %{"age" => 25, "email" => "bob@example.com", "name" => "Bob"}
  ✅ SUCCESS: %NonStrictSchema{age: 25, email: "bob@example.com", name: "Bob"}

---
STRICT - Mixed keys:
  Input: %{"age" => 35, "email" => "charlie@example.com", "name" => "Charlie"}
  ❌ ERROR: [%Exdantic.Error{path: [], code: :additional_properties, message: "unknown fields: [\"age\", \"email\", \"name\"]"}]

NON-STRICT - Mixed keys:
  Input: %{"age" => 35, "email" => "charlie@example.com", "name" => "Charlie"}
  ✅ SUCCESS: %NonStrictSchema{age: 35, email: "charlie@example.com", name: "Charlie"}

---
STRICT - Extra fields (atom keys):
  Input: %{active: true, name: "David", email: "david@example.com", age: 40, role: "admin"}
  ❌ ERROR: [%Exdantic.Error{path: [], code: :additional_properties, message: "unknown fields: [:active, :role]"}]

NON-STRICT - Extra fields (atom keys):
  Input: %{active: true, name: "David", email: "david@example.com", age: 40, role: "admin"}
  ✅ SUCCESS: %NonStrictSchema{age: 40, email: "david@example.com", name: "David"}

---
STRICT - Extra fields (string keys):
  Input: %{"age" => 28, "created_at" => "2024-01-01", "email" => "eve@example.com", "name" => "Eve", "role" => "user"}
  ❌ ERROR: [%Exdantic.Error{path: [], code: :additional_properties, message: "unknown fields: [\"age\", \"created_at\", \"email\", \"name\", \"role\"]"}]

NON-STRICT - Extra fields (string keys):
  Input: %{"age" => 28, "created_at" => "2024-01-01", "email" => "eve@example.com", "name" => "Eve", "role" => "user"}
  ✅ SUCCESS: %NonStrictSchema{age: 28, email: "eve@example.com", name: "Eve"}

---
STRICT - Missing required field:
  Input: %{name: "Frank"}
  ❌ ERROR: [%Exdantic.Error{path: [:email], code: :required, message: "field is required"}]

NON-STRICT - Missing required field:
  Input: %{name: "Frank"}
  ❌ ERROR: [%Exdantic.Error{path: [:email], code: :required, message: "field is required"}]

---
STRICT - Invalid email:
  Input: %{name: "Grace", email: "invalid-email", age: 32}
  ❌ ERROR: [%Exdantic.Error{path: [:email], code: :format, message: "failed format constraint"}]

NON-STRICT - Invalid email:
  Input: %{name: "Grace", email: "invalid-email", age: 32}
  ❌ ERROR: [%Exdantic.Error{path: [:email], code: :format, message: "failed format constraint"}]

---
STRICT - Nested extra data:
  Input: %{"age" => 45, "email" => "henry@example.com", "metadata" => %{"source" => "api", "version" => "v2"}, "name" => "Henry", "profile" => %{"bio" => "Software engineer", "skills" => ["elixir", "rust"]}}
  ❌ ERROR: [%Exdantic.Error{path: [], code: :additional_properties, message: "unknown fields: [\"age\", \"email\", \"metadata\", \"name\", \"profile\"]"}]

NON-STRICT - Nested extra data:
  Input: %{"age" => 45, "email" => "henry@example.com", "metadata" => %{"source" => "api", "version" => "v2"}, "name" => "Henry", "profile" => %{"bio" => "Software engineer", "skills" => ["elixir", "rust"]}}
  ✅ SUCCESS: %NonStrictSchema{age: 45, email: "henry@example.com", name: "Henry"}

---
=== REAL-WORLD JSON API SCENARIO ===

Typical JSON from API:
{
  "name": "API User",
  "email": "user@api.com",
  "age": 29,
  "id": "12345",
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:00Z",
  "metadata": {
    "source": "registration",
    "ip_address": "192.168.1.1"
  }
}

Parsed JSON: %{"age" => 29, "created_at" => "2024-01-15T10:30:00Z", "email" => "user@api.com", "id" => "12345", "metadata" => %{"ip_address" => "192.168.1.1", "source" => "registration"}, "name" => "API User", "updated_at" => "2024-01-15T10:30:00Z"}

STRICT - Real API JSON:
  Input: %{"age" => 29, "created_at" => "2024-01-15T10:30:00Z", "email" => "user@api.com", "id" => "12345", "metadata" => %{"ip_address" => "192.168.1.1", "source" => "registration"}, "name" => "API User", "updated_at" => "2024-01-15T10:30:00Z"}
  ❌ ERROR: [%Exdantic.Error{path: [], code: :additional_properties, message: "unknown fields: [\"age\", \"created_at\", \"email\", \"id\", \"metadata\", \"name\", \"updated_at\"]"}]

NON-STRICT - Real API JSON:
  Input: %{"age" => 29, "created_at" => "2024-01-15T10:30:00Z", "email" => "user@api.com", "id" => "12345", "metadata" => %{"ip_address" => "192.168.1.1", "source" => "registration"}, "name" => "API User", "updated_at" => "2024-01-15T10:30:00Z"}
  ✅ SUCCESS: %NonStrictSchema{age: 29, email: "user@api.com", name: "API User"}

=== PERFORMANCE IMPLICATIONS ===

Performance (1000 validations):
  Strict mode (valid data): 1.59ms
  Non-strict mode (valid data): 1.26ms
  Strict mode (extra fields): 5.49ms
  Non-strict mode (extra fields): 1.56ms

=== KEY INSIGHTS ===
1. Strict mode ONLY works with atom keys in practice
2. Non-strict mode handles both atom and string keys gracefully
3. Real-world JSON APIs always have extra fields
4. String keys are the norm for external data
5. Strict mode limits interoperability significantly