Getting Started with CodecMapper
This tutorial teaches the main authored-schema path first.
The goal is simple:
- define one schema
- compile it once
- serialize and deserialize with the same contract
Leave the C# bridge, JSON Schema import, and other side paths until this flow feels natural.
The smallest complete example
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 codec = Json.compile personSchema
let person = { Id = 1; Name = "Ada" }
let json = Json.serialize codec person
let decoded = Json.deserialize codec json
That is the normal shape of the library:
- author a schema
- compile it into a codec
- reuse that codec for both directions
How to read the schema
Read the schema pipeline from top to bottom:
define<Person>says which value the contract describesconstruct makePersonsays how decode rebuilds the valuefield "id" _.Idmaps the wire field"id"to the record fieldIdbuildfinishes the schema
The important idea is that the schema is the contract itself, not a hint to a serializer.
What compilation means
The schema definition is still just data about the contract. Json.compile turns that definition into a reusable codec:
let codec = Json.compile personSchema
That explicit step matters because CodecMapper is designed for reuse. You compile once, then serialize and deserialize many values with the same codec.
If the schema is only being authored inline at the end of a short example, Json.buildAndCompile is a convenience:
let codec =
define<Person>
|> construct makePerson
|> field "id" _.Id
|> field "name" _.Name
|> Json.buildAndCompile
Use that helper for small inline examples. Keep Json.compile personSchema when the schema has a name, is reused, or is referenced by other schemas.
The next step: nested data
A child record usually gets its own schema:
type Address = { Street: string; City: string }
let makeAddress street city = { Street = street; City = city }
type Person = { Id: int; Name: string; Home: Address }
let makePerson id name home = { Id = id; Name = name; Home = home }
let addressSchema =
define<Address>
|> construct makeAddress
|> field "street" _.Street
|> field "city" _.City
|> build
let personSchema =
define<Person>
|> construct makePerson
|> field "id" _.Id
|> field "name" _.Name
|> fieldWith "home" _.Home addressSchema
|> build
fieldWith marks an explicit schema boundary for the child value.
The next step: stronger domain types
If the wire value is simple but the in-memory value should be validated, refine the schema with tryMap:
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
That keeps the wire contract simple while making the domain type stricter.
Where to go next
Take the next pages in this order:
- How To Model A Basic Record
- How To Model A Nested Record
- How To Model A Validated Wrapper
- How To Model A Versioned Contract
Once the authored-schema path is clear:
- use How To Import Existing C# Contracts for bridge or C# facade work
- use How To Export JSON Schema for outward schema documents
- use JSON Schema in CodecMapper when you need the design reasoning
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val string: value: 'T -> string
--------------------
type string = System.String
union case UserId.UserId: int -> UserId
--------------------
type UserId = | UserId of int
union case UserId.UserId: int -> UserId
--------------------
module UserId from GETTINGSTARTED
--------------------
type UserId = | UserId of int
CodecMapper