Sinter can convert its schema definitions into standard JSON Schema documents. This is useful for integrating with LLM providers, generating API documentation, and validating data interchange formats.
Basic Generation
Use Sinter.JsonSchema.generate/2 to convert a Sinter schema into a JSON Schema
map. By default, it produces a Draft 2020-12 schema.
schema = Sinter.Schema.define([
{:name, :string, [required: true, min_length: 2]},
{:age, :integer, [optional: true, gt: 0]},
{:tags, {:array, :string}, [optional: true, max_items: 10]}
], title: "User")
json_schema = Sinter.JsonSchema.generate(schema)
# Returns:
# %{
# "$schema" => "https://json-schema.org/draft/2020-12/schema",
# "type" => "object",
# "title" => "User",
# "properties" => %{
# "name" => %{"type" => "string", "minLength" => 2},
# "age" => %{"type" => "integer", "exclusiveMinimum" => 0},
# "tags" => %{"type" => "array", "items" => %{"type" => "string"}, "maxItems" => 10}
# },
# "required" => ["name"],
# "additionalProperties" => true,
# "x-sinter-version" => "0.3.1",
# "x-sinter-field-count" => 3,
# "x-sinter-created-at" => "2026-03-12T..."
# }Sinter maps its constraint options to their JSON Schema equivalents automatically:
| Sinter constraint | JSON Schema keyword |
|---|---|
min_length | minLength |
max_length | maxLength |
gt | exclusiveMinimum |
gteq | minimum |
lt | exclusiveMaximum |
lteq | maximum |
min_items | minItems |
max_items | maxItems |
format (Regex) | pattern |
choices | enum |
Draft Selection
Sinter supports two JSON Schema drafts. The default is Draft 2020-12; pass the
:draft option to select Draft 7.
schema = Sinter.Schema.define([
{:name, :string, [required: true]}
])
# Draft 2020-12 (default)
d2020 = Sinter.JsonSchema.generate(schema)
d2020["$schema"]
#=> "https://json-schema.org/draft/2020-12/schema"
# Draft 7
d7 = Sinter.JsonSchema.generate(schema, draft: :draft7)
d7["$schema"]
#=> "http://json-schema.org/draft-07/schema#"When you use a provider optimization (:openai or :anthropic), the draft
defaults to :draft7 unless you explicitly override it. The :generic provider
keeps the default of :draft2020_12.
Provider Optimizations
Sinter.JsonSchema.for_provider/3 generates a JSON Schema tailored to a
specific LLM provider. It is a convenience wrapper around generate/2 that
sets optimize_for_provider for you.
schema = Sinter.Schema.define([
{:question, :string, [required: true, description: "The user question"]},
{:answer, :string, [required: true]},
{:confidence, :float, [optional: true, gteq: 0.0, lteq: 1.0]}
])OpenAI (function calling)
openai_schema = Sinter.JsonSchema.for_provider(schema, :openai)Optimizations applied:
- Sets
additionalProperties: falseat every object level (required by OpenAI's strict function calling mode). - Ensures a
requiredarray is always present, even when empty. - Removes formats that OpenAI does not support well (
"date","time","email"). - Simplifies union types (
oneOf) with more than three variants down to the first three, since large unions degrade function calling reliability. - Defaults to Draft 7.
Anthropic (tool use)
anthropic_schema = Sinter.JsonSchema.for_provider(schema, :anthropic)Optimizations applied:
- Sets
additionalProperties: falseat every object level. - Ensures a
requiredarray is always present. - Removes formats not well-supported by Anthropic (
"uri","uuid"). - Guarantees that every object-typed schema has a
propertieskey, even if it is an empty map. - Defaults to Draft 7.
Generic
generic_schema = Sinter.JsonSchema.for_provider(schema, :generic)No provider-specific transformations are applied. The output is identical to
calling Sinter.JsonSchema.generate/2 directly.
You can also pass additional options as the third argument:
Sinter.JsonSchema.for_provider(schema, :openai,
include_descriptions: false,
flatten: true
)Strict Mode
When strict: true is set -- either on the schema itself or as a generation
option -- additionalProperties: false is applied recursively to every nested
object in the output.
schema = Sinter.Schema.define([
{:profile, {:object, [
{:name, :string, [required: true]},
{:address, {:object, [
{:city, :string, [required: true]}
]}, [required: true]}
]}, [required: true]}
])
# Without strict mode
relaxed = Sinter.JsonSchema.generate(schema)
relaxed["additionalProperties"] #=> true
relaxed["properties"]["profile"]["additionalProperties"] #=> true
relaxed["properties"]["profile"]["properties"]["address"]["additionalProperties"] #=> true
# With strict mode
strict = Sinter.JsonSchema.generate(schema, strict: true)
strict["additionalProperties"] #=> false
strict["properties"]["profile"]["additionalProperties"] #=> false
strict["properties"]["profile"]["properties"]["address"]["additionalProperties"] #=> falseThe strict option on generate/2 overrides whatever the schema's own
strict setting is. Provider optimizations for :openai and :anthropic
always apply recursive strictness regardless of this flag.
Options
Sinter.JsonSchema.generate/2 accepts the following options:
| Option | Default | Description |
|---|---|---|
:draft | :draft2020_12 | JSON Schema draft version (:draft2020_12 or :draft7). Provider targets default to :draft7. |
:include_descriptions | true | Whether to include description annotations on fields. |
:flatten | false | Inline all $ref references, producing a self-contained schema. |
:optimize_for_provider | :generic | Apply provider-specific transformations (:openai, :anthropic, or :generic). |
:strict | schema default | Override the schema's strict setting. Applies additionalProperties: false recursively. |
Excluding Descriptions
Field descriptions increase token count when schemas are sent to LLM providers. Disable them to save tokens:
schema = Sinter.Schema.define([
{:name, :string, [required: true, description: "The user's full name"]}
])
compact = Sinter.JsonSchema.generate(schema, include_descriptions: false)
Map.has_key?(compact["properties"]["name"], "description")
#=> falseFlattening References
The :flatten option resolves all $ref pointers inline, producing a
self-contained document with no external references:
Sinter.JsonSchema.generate(schema, flatten: true)Schema Validation
Sinter.JsonSchema.validate_schema/2 checks whether a JSON Schema map is
structurally valid by building it with JSV against the appropriate meta-schema.
valid = %{
"type" => "object",
"properties" => %{
"name" => %{"type" => "string"}
},
"required" => ["name"]
}
:ok = Sinter.JsonSchema.validate_schema(valid)
invalid = %{
"type" => "not-a-real-type",
"minLength" => "should-be-integer"
}
{:error, issues} = Sinter.JsonSchema.validate_schema(invalid)
# issues is a list of error message stringsYou can also specify the draft to validate against:
Sinter.JsonSchema.validate_schema(schema_map, draft: :draft7)
Sinter.JsonSchema.validate_schema(schema_map, draft: :draft2020_12)This is useful as a final check before sending generated schemas to an external service.
Metadata
Sinter automatically attaches extension metadata to every generated JSON Schema at the top level:
| Key | Value |
|---|---|
x-sinter-version | The Sinter library version that generated it. |
x-sinter-field-count | Number of fields defined in the source schema. |
x-sinter-created-at | ISO 8601 timestamp of when the schema was created. |
schema = Sinter.Schema.define([
{:a, :string, [required: true]},
{:b, :integer, [optional: true]}
])
json_schema = Sinter.JsonSchema.generate(schema)
json_schema["x-sinter-version"] #=> "0.3.1"
json_schema["x-sinter-field-count"] #=> 2
json_schema["x-sinter-created-at"] #=> "2026-03-12T12:00:00.000000Z"These keys use the x- extension prefix and are ignored by standard JSON Schema
validators.
Discriminated Unions
Discriminated unions are emitted as oneOf branches with a JSON Schema
discriminator. Each branch keeps the same detail you would get from generating
that variant as a standalone schema: nested object properties, aliases,
constraints, descriptions, defaults, examples, and strict
additionalProperties settings are all preserved.
text_variant = Sinter.Schema.define([
{:type, {:literal, "text"}, [required: true]},
{:content, :string, [required: true, min_length: 1]}
])
image_variant = Sinter.Schema.define([
{:type, {:literal, "image"}, [required: true]},
{:url, :string, [required: true]},
{:caption, :string, [optional: true]}
], strict: true)
schema = Sinter.Schema.define([
{:chunk,
{:discriminated_union,
[
discriminator: "type",
variants: %{
"text" => text_variant,
"image" => image_variant
}
]}, [required: true]}
])
json_schema = Sinter.JsonSchema.generate(schema)
chunk_schema = json_schema["properties"]["chunk"]The discriminator field is always listed as required for each branch, even if a variant marks it optional, so generated JSON Schema matches Sinter's runtime selection logic.
Sinter also emits definition entries for discriminator mappings. In Draft
2020-12 output these live under $defs; in Draft 7 output they live under
definitions.
chunk_schema["discriminator"]["mapping"]
#=> %{
#=> "image" => "#/$defs/properties__chunk__image",
#=> "text" => "#/$defs/properties__chunk__text"
#=> }
json_schema["$defs"]["properties__chunk__text"]["properties"]["content"]
#=> %{"minLength" => 1, "type" => "string"}Field Aliases
When a field has an :alias option, the alias is used as the property name in
the generated JSON Schema instead of the canonical Elixir field name. This lets
you keep idiomatic snake_case names in Elixir while producing camelCase (or any
other convention) in the JSON output.
schema = Sinter.Schema.define([
{:account_name, :string, [required: true, alias: "accountName"]},
{:created_at, :datetime, [required: true, alias: "createdAt"]},
{:is_active, :boolean, [optional: true, alias: "isActive"]}
])
json_schema = Sinter.JsonSchema.generate(schema)
Map.keys(json_schema["properties"])
#=> ["accountName", "createdAt", "isActive"]
json_schema["required"]
#=> ["accountName", "createdAt"]Aliases affect both the properties map keys and the required array entries.
The canonical field names are still used internally by Sinter.Validator when
validating Elixir data.