Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultGenerated Code
schemagen writes one companion file per source file that contains derived declarations. This page lists what that
file contains, so you can call it, read it in review, and put your own code beside it.
The companion never re-declares your types. Records and unions stay in your source file; the generated file only adds modules that refer to them.
Where it lives
| Source container | Generated container | Access from your code |
|---|---|---|
namespace MyApp.Wire |
the same namespace, namespace MyApp.Wire |
Order.parse after open MyApp.Wire |
module MyApp.Wire (file-level module) |
a new top-level module, module MyApp.WireSchemas |
open MyApp.WireSchemas, then Order.parse |
With the default Intermediate mode the file is written under obj/. With ReifiedSchemaGeneratedFiles=CheckedIn it
is written as Wire.g.fs beside Wire.fs. In both cases the build inserts it immediately after its source file in
compile order, so every later file can use it and the source file itself cannot.
Every generated file starts with an <auto-generated> header naming its source and opens Reified and
Reified.SchemaDSL. A file with a self-referencing record also gets #nowarn "40".
For each [<DeriveSchema>] record
Given:
namespace MyApp.Wire
open Reified.DerivedSchema
/// A customer order line.
[<DeriveSchema>]
type Order =
{ [<SchemaName "product_code"; Present>]
Code: string
[<AtLeast 1>]
Quantity: int
Note: string option }the companion contains one module with the record's name:
/// Schema for Order.
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
[<RequireQualifiedAccess>]
module Order =
open Reified.ConstraintDSL // only when a field has constraints
type private Model = Order
open type Model
let schema =
schema<Model> {
fieldAs "product_code" _.Code {
constrain present
}
fieldAs "quantity" _.Quantity {
constrain (atLeast 1)
}
fieldAs "note" _.Note
construct (fun code quantity note ->
{ Code = code
Quantity = quantity
Note = note })
}
|> Schema.describe "A customer order line."
let validate = Schema.check schema
let parse = Schema.parse schema| Binding | Type | Meaning |
|---|---|---|
Order.schema |
Schema<Order> |
The full schema: fields, wire names, constraints, defaults, descriptions. Use it with Json.compile, JsonSchema.generate, Schema.admit, forms, and inspection. |
Order.parse |
Data -> Result<Order, SchemaErrors> |
Schema.parse schema. |
Order.validate |
Order -> Result<Order, SchemaErrors> |
Schema.check schema: re-checks a value you built in code. |
Details that follow from the emitted shape:
- The module is
[<RequireQualifiedAccess>]. Always writeOrder.parse, neverparse. - The compiled name is
OrderModule. It shares the F# nameOrderwith your type, so C# callers seeOrderModule.parse. - The constructor is a record literal by default. With a
[<SchemaConstructor>]static member, theconstructline calls that member instead:construct (fun code quantity note -> Order.create code quantity note). - A self-referencing record gets
let rec schemaandSchema.deferat the recursive field. - Field schemas for other derived types are referenced, not copied:
withSchema Customer.schema,withSchema (Schema.listWith Status.schema). Cross-file references add anopenof the owning scope, or use the full name when a short name would be ambiguous. - No
static member Schemais added to your record. A hand-writtenschema<_> { ... }that has a field of a derived record type must saywithSchema Order.schema; barefieldAs "order" _.Orderdoes not find it.
For each version series
When records form a version series (OrderV1, OrderV2, Order, or explicit Contract/Version), every version
gets the module above. The current version's module also gets a contract builder:
/// Builds the versioned wire contract; supply each n-1 -> n migration and the version-detection source.
let contract
(migrateV1ToV2: OrderV1 -> Result<OrderV2, MigrationError>)
(migrateV2ToV3: OrderV2 -> Result<Order, MigrationError>)
(source: VersionSource)
: Contract<Order> =
Contract.create "Order" 3 schema
|> Contract.supersedes 2 OrderV2.schema migrateV2ToV3
|> Contract.supersedes 1 OrderV1.schema migrateV1ToV2
|> Contract.build sourceThe generator never writes migrations. You pass them in. Adding a version adds a parameter, so every call site stops compiling until you supply the new migration.
When an older version is declared in another file, namespace, or module, the builder refers to it by its full name,
for example My.Legacy.OldOrder and My.LegacySchemas.OldOrder.schema.
For unions and wrapper types
A derived record's field types can generate their own modules, emitted once in the file that declares the type:
| Declaration | Generated module contents |
|---|---|
nullary union (Free \| Team) or [<DeriveEnum>] |
Plan.schema — Schema.enum [ EnumCase.create "free" Plan.Free; ... ] |
single-case wrapper (type Sku = Sku of string) |
Sku.schema — Schema.convert Sku (function Sku v -> v) Schema.text; plus Sku.map, a Schema.mapWithKey helper, when it is used as a Map key |
[<DeriveUnion>] union |
Payment.schema — Schema.union [...] (or Schema.unionWith for a non-default representation), with private try<Case>Case extractors and private <Case>CasePayload records for multi-field cases |
These modules are [<RequireQualifiedAccess>] like record modules. A union's
schema is emitted once in its declaring file and reused from everywhere else.
What is not generated
- Mapping between wire and domain types. A wire record is permissive. Your domain type decides what is valid.
- Migrations, which are the
contractbuilder's parameters. - Codecs. Build one with
Json.compile Order.schema. - Record copies or
Fieldsmodules. Derived records are yours; the generator only refers to them.
Adding your own functions
F# modules are closed: you cannot add bindings to the generated Order module, and declaring a second module Order
in the same namespace is a duplicate-name error. Put your code in a file listed after the source file. It then
sees the whole companion.
<Compile Include="Wire.fs" /> <!-- [<DeriveSchema>] records; companion is inserted here -->
<Compile Include="Orders.fs" /> <!-- domain type, mappings, anything using Order.schema -->
The usual home is the domain type's module:
namespace MyApp.Domain
open Reified
open MyApp.Wire
type OrderLine = private { Code: string; Quantity: int }
module OrderLine =
let ofWire (wire: Order) : Result<OrderLine, SchemaError list> =
if wire.Code.StartsWith "X-" then
Error [ SchemaError.ConstructorFailed "retired product codes are not accepted" ]
else
Ok { Code = wire.Code; Quantity = wire.Quantity }
let toWire (line: OrderLine) : Order =
{ Code = line.Code; Quantity = line.Quantity; Note = None }
/// The generated wire schema, admitted into the domain type.
let schema : Schema<OrderLine> = Order.schema |> Schema.admit ofWire toWire
let parse = Schema.parse schemaSchema.admit keeps the generated fields, wire names, constraints, and descriptions, and runs ofWire as the last
step of parsing. OrderLine.parse, Json.compile OrderLine.schema, and JsonSchema.generate OrderLine.schema then
all work with the domain type, and a rejected admission is reported as an ordinary parse error.
Two hooks run inside generated code rather than after it:
[<SchemaConstructor>]on the wire record, for normalisation during parsing (trimming, lowercasing). It is declared in the source file, before the companion, so it cannot refer toOrder.schema.- Migrations passed to
contract, for converting between wire versions.
See Separate Wire and Domain Models for the full boundary pattern.