Header menu logo CodecMapper

How To Export JSON Schema

Use JsonSchema.generate when you want a JSON Schema document for the JSON wire contract already described by a Schema<'T>.

This is the authored-schema path. It is separate from JSON Schema import:

Keep those two workflows separate when you design your integration boundary.

Export a schema

open CodecMapper
open CodecMapper.Schema

type Person = { Id: int; Name: string }
let makePerson id name = { Id = id; Name = name }

let personSchema =
    define<Person>
    |> construct makePerson
    |> field "id" _.Id
    |> field "name" _.Name
    |> build

let jsonSchema = JsonSchema.generate personSchema

jsonSchema is a compact draft 2020-12 JSON Schema document as a string.

Export validated wrapper types

Schema.map and Schema.tryMap export the underlying wire shape, not the domain-only refinement rule:

open CodecMapper.Schema

type UserId = UserId of int

module UserId =
    let create value =
        if value > 0 then Ok(UserId value)
        else Error "UserId must be positive"

    let value (UserId value) = value

let userIdSchema =
    int
    |> tryMap UserId.create UserId.value

let schemaText = JsonSchema.generate userIdSchema

That schema still exports as an integer contract because the JSON wire value is still an integer.

Export optional fields

Schema.option keeps explicit null semantics:

open CodecMapper.Schema

let maybeAgeSchema = option int
let schemaText = JsonSchema.generate maybeAgeSchema

That exports as anyOf with the inner schema plus null.

If you use Schema.missingAsNone inside a record field, the field is removed from the enclosing object's required list, but its value shape stays the same.

Know what is and is not exported

JsonSchema.generate exports the structural JSON contract:

It does not infer extra validation keywords from smart constructors or arbitrary business rules. If your type enforces domain constraints through Schema.tryMap, keep doing that on decode; the exported JSON Schema remains the structural contract.

Use the raw fallback for non-deterministic imported shapes

If an imported schema cannot be lowered into a normal record/array/primitive contract, use Schema.jsonValue as the escape hatch:

let codec = Json.compile Schema.jsonValue

That keeps the dynamic case explicit instead of weakening the common typed path. Schema.jsonValue is intended for JSON-only fallback scenarios such as dynamic-key objects, tuple-like arrays, or schemas that need a separate normalization step before stronger typing is possible.

Import a JSON Schema for receive-side validation

If you are receiving payloads from an external JSON Schema contract, import it into Schema<JsonValue>:

let imported =
    JsonSchema.import """{
        "type":"object",
        "properties":{
            "id":{"type":"integer"},
            "name":{"type":"string"}
        },
        "required":["id"]
    }"""

let codec = Json.compile imported
let value = Json.deserialize codec """{"id":42,"name":"Ada"}"""

This path preserves the incoming JSON shape as JsonValue. It enforces the supported structural subset and leaves unsupported branch-heavy features on the raw JSON fallback path.

This is not a round-trip back into a typed authored schema. It is a receive-side integration boundary for external schema-owned contracts.

If you need to know what was enforced, use JsonSchema.importWithReport:

let report = JsonSchema.importWithReport schemaText
let codec = Json.compile report.Schema

That report exposes enforced keywords, fallback keywords, and warnings from local $ref normalization.

It also exposes NormalizedKeywords, which is where keywords such as $ref and allOf show up after schema preprocessing.

Fallback keywords are intentional diagnostics, not silent downgrades. For example, if an imported schema uses unsupported keywords such as dependentSchemas or not alongside supported keywords such as type or minLength, the supported sibling rules still enforce normally while the unsupported keyword is reported in FallbackKeywords.

Supply a custom format validator

Use JsonSchema.importUsing or JsonSchema.importWithReportUsing when your schema uses a project-specific format:

let options =
    JsonSchema.ImportOptions.defaults
    |> JsonSchema.ImportOptions.withFormat "upper-code" (fun value ->
        if value.ToUpperInvariant() = value then
            Ok()
        else
            Error "String did not match the upper-code format")

let codec =
    Json.compile (
        JsonSchema.importUsing options """{"type":"string","format":"upper-code"}"""
    )

The built-in defaults already cover uuid and date-time. Add custom validators only for formats your application actually relies on.

Handle advanced dynamic-shape schemas

For external receive-side schemas that use keywords such as oneOf, anyOf, if / then / else, patternProperties, or prefixItems, keep the boundary explicit:

let report = JsonSchema.importWithReport schemaText
let codec = Json.compile report.Schema

That path parses into JsonValue and enforces the supported dynamic-shape keywords over the raw JSON structure. It is appropriate for external contracts you do not control. For contracts you author yourself, prefer normal explicit Schema<'T> values.

Example:

open CodecMapper

let schemaText =
    """{
        "type":"object",
        "propertyNames":{"pattern":"^[a-z-]+$"},
        "patternProperties":{
            "^x-":{"type":"integer"}
        },
        "additionalProperties":{"type":"string"}
    }"""

let report = JsonSchema.importWithReport schemaText
let codec = Json.compile report.Schema

let value = Json.deserialize codec """{"x-rate":1,"name":"Ada"}"""

printfn "%A" report.EnforcedKeywords
printfn "%A" value

Output:

["type"; "additionalProperties"; "patternProperties"; "propertyNames"]
JObject
  [("x-rate", JNumber "1"); ("name", JString "Ada")]

This is still a JsonValue receive path, not a lowered record schema. The importer is enforcing the dynamic object rules over the raw JSON shape.

For unsupported keywords that are intentionally out of scope for now, inspect FallbackKeywords explicitly:

let report =
    JsonSchema.importWithReport
        """{
            "type":"string",
            "minLength":2,
            "not":{"const":"blocked"}
        }"""

printfn "%A" report.EnforcedKeywords
printfn "%A" report.FallbackKeywords

Output:

["type"; "minLength"]
["not"]

That means the supported sibling rules still enforce, while not remains on the fallback boundary instead of being partially modeled.

type Person = { Id: int Name: string }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val makePerson: id: int -> name: string -> Person
val id: int
val name: string
val personSchema: obj
val jsonSchema: obj
Multiple items
union case UserId.UserId: int -> UserId

--------------------
type UserId = | UserId of int
type UserId = | UserId of int
val create: value: int -> Result<UserId,string>
val value: int
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val value: UserId -> int
val userIdSchema: obj
Multiple items
union case UserId.UserId: int -> UserId

--------------------
module UserId from HOWTOEXPORTJSONSCHEMA

--------------------
type UserId = | UserId of int
val schemaText: obj
val maybeAgeSchema: obj
type 'T option = Option<'T>
val codec: obj
val imported: obj
val value: obj
val report: obj
val options: obj
val schemaText: string
val printfn: format: Printf.TextWriterFormat<'T> -> 'T

Type something to start searching.