Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result

Schema Inference

schemagen parses F# source and lowers marked declarations to typed Schema DSL. It does not load your assembly or inspect types at runtime.

Record shape

A derivable record must be:

  • public and declared directly in a namespace or file-level module;
  • non-generic;
  • in a file with one namespace or one file-level module for generated declarations; and
  • marked with [<DeriveSchema>].

Every record field becomes one schema field in declaration order. By default the generated construct expression creates a record literal. A single [<SchemaConstructor>] static member replaces that literal; it must accept fields in declaration order and return the record type.

The generated module uses the record's type name and exposes schema, parse, and validate. Generated code refers to your fields by name. Adding, removing, renaming, or changing a field without regenerating makes F# compilation fail rather than allowing the schema to drift.

Field type inference

F# field type Inferred schema
string Schema.text
int Schema.int
decimal Schema.decimal
bool Schema.bool
DateOnly Schema.date
DateTimeOffset Schema.dateTime
Guid Schema.guid
'a option optional field using the inferred 'a schema
'a list Schema.listWith the inferred element schema
Map<string, 'a> Schema.mapWith the inferred value schema
Map<'key, 'a> where 'key is a single-case union over string Schema.mapWithKey using the union case as the reversible property-name conversion
another marked record in the same file that record's generated schema
a fully qualified marked record in another project source file that record's generated schema
a nullary discriminated union enum schema; case names become tags
a [<DeriveUnion>] union internally tagged union using type, with fieldless, directly named, and marked-record payload cases
a [<DeriveUnion "field">] union the same internally tagged union with a custom discriminator

The wire vocabulary is intentionally closed. Arrays, tuples, generic records, nested options, floating-point types, map keys other than strings and transparent single-case string unions, other integer widths, and unknown application types produce generation diagnostics. Use list, decimal, int, or an explicitly mapped domain boundary instead of silently changing wire semantics.

Cross-file record and union references must use their fully qualified F# type paths. schemagen builds a project-wide catalogue before generating companions; an ambiguous short name is rejected rather than resolved by compile order.

Names

The default naming policy is camel, so MarketingOptIn becomes marketingOptIn. Set ReifiedSchemaNaming to snake or verbatim for the whole project. Use [<SchemaName "marketing_opt_in">] on one field or nullary union case to override the policy locally.

Options, supplied fields, and defaults

An option field represents wire absence and parses an omitted key as None. There is no second nested-option absence axis. [<Supplied>] is different: it requires the key to occur in the input even when the typed value could otherwise be produced. [<Default value>] supplies an omitted non-optional field and cannot be used on an option field.

Constraints and metadata

Field attributes are lowered in source order to the operations documented in the attribute table. Constraints remain executable and inspectable, so parsing, Schema.check, JSON Schema, forms, and diagnostics all observe the same rule. Format is metadata only. XML /// comments become Schema.describe metadata and generated XML documentation.

Union inference

A union whose cases have no payload can be used as a field type without marking the union:

type Plan = Free | Team | Enterprise

[<DeriveSchema>]
type Signup = { Plan: Plan }

Tags follow the naming policy and can be overridden with [<SchemaName>] on a case.

Payload unions opt into generation with [<DeriveUnion>]. A fieldless case may be mixed with directly named case fields or cases carrying a marked record. Derived unions may also appear below another union payload or inside a list or map:

[<DeriveSchema>]
type Card = { LastFour: string }

[<DeriveSchema>]
type Bank = { Iban: string }

[<DeriveUnion>]
type Payment =
    | Cash
    | Credit of limit: decimal
    | Card of Card
    | Bank of Bank

[<DeriveSchema>]
type Checkout = { Payment: Payment }

The generated schema follows the handwritten union schema rules. Each fully qualified union type gets one generated schema binding. Records, nested union payloads, lists, maps, and references from other source files reuse that binding instead of generating field-named copies. A generated try<CaseName>Case function selects each direct-field case. Its case block names that function with tryExtract, then uses the same fieldAs, withSchema, and construct vocabulary as a record schema. Multi-field cases use one private payload record so every getter remains named and total.

Enum and transparent-wrapper schemas live in the generated file beside their F# type. Other generated files reuse bindings such as VariableType.schema, LocaleTag.schema, and LocaleTag.map instead of repeating conversions.

Every direct case field must be named. Credit of decimal is rejected instead of publishing the compiler-generated wire name item.

Version-series inference

Names ending in Vn are grouped by convention. For example, ProfileV1, ProfileV2, and a bare Profile form a contiguous series, with the bare record inferred as the current version. Use [<DeriveSchema(Chain = "Profile", Version = 1)>] when names do not follow the convention. A series additionally gets a typed contract builder whose parameters are the explicit migrations between adjacent versions; see Versioned Contracts.