Fable.TypedJson 5.1.0

dotnet add package Fable.TypedJson --version 5.1.0
                    
NuGet\Install-Package Fable.TypedJson -Version 5.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Fable.TypedJson" Version="5.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Fable.TypedJson" Version="5.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Fable.TypedJson" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Fable.TypedJson --version 5.1.0
                    
#r "nuget: Fable.TypedJson, 5.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Fable.TypedJson@5.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Fable.TypedJson&version=5.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Fable.TypedJson&version=5.1.0
                    
Install as a Cake Tool

Fable.TypedJson

Build and Test NuGet

Pydantic-flavored JSON validation and serialization for F# records, designed for Fable's multi-backend output. BEAM (Erlang), Python, JavaScript, and .NET all work today.

Point it at a record type and it derives the decoder, the encoder, and a JSON Schema — from one walk of the type, so the three can't disagree.

open Fable.TypedJson.Json
open Fable.TypedJson.Beam.Json      // or .Python.Json / .JS.Json / .DotNet.Json

type Reading = { Location: string; AirTemperature: float }

let codec = auto<Reading> ()

codec.decode (parseRaw """{"location":"Oslo","airTemperature":22.5}""")
// Ok { Location = "Oslo"; AirTemperature = 22.5 }

codec.encode { Location = "Oslo"; AirTemperature = 22.5 }
// {"location":"Oslo","airTemperature":22.5}

Install

Install the core package plus the shim for the target you compile to:

dotnet add package Fable.TypedJson
dotnet add package Fable.TypedJson.Beam     # pick one shim
Target Package Runs on
BEAM (Erlang) Fable.TypedJson.Beam Fable → Erlang, over jsx
Python Fable.TypedJson.Python Fable → Python, over json
JavaScript Fable.TypedJson.JS Fable → JS, over JSON.parse
.NET Fable.TypedJson.DotNet the CLR natively, over System.Text.Json

Core and the Fable shims target netstandard2.0; Fable.TypedJson.DotNet targets net10.0. Opening the backend's Json module pre-applies the backend, so auto takes () and you never thread a backend value yourself.

Build a codec once and reuse it. Construction resolves the entire type tree — every nested record, list element and union case — so decoding does no reflection at any depth. Bind codecs at module level, not per request.

let codec = autoWith<WeatherRequest> codecs   // module level

Validation lives with the type

The headline idea: define a wrapper DU, give it a JsonCodec static member, and auto<'T>() discovers it and dispatches through it — the F# answer to Pydantic's "custom types with embedded validators."

open Fable.TypedJson             // brings the module name `Codec` into scope
open Fable.TypedJson.Schema      // IJsonCodec, emptyRegistry, register, formatErrors
open Fable.TypedJson.Refined     // NonEmptyString, PositiveInt, Email, Url, Uuid
open Fable.TypedJson.Json        // auto, autoWith, CaseRules, withModel, alias
open Fable.TypedJson.Beam.Json

// 1. A wrapper type. The pipeline composes validators the same way
//    Pydantic composes  Annotated[int, Field(gt=0, le=14)].
type Days =
    | Days of int

    static member JsonCodec: IJsonCodec<Days> =
        Codec.int |> Codec.gt 0 |> Codec.le 14
        |> Codec.map Days (fun (Days n) -> n)

// 2. Use it like any field type. Optional fields handle missing-as-None natively.
type WeatherRequest = {
    Location: NonEmptyString          // bundled refined type
    Days: Days                        // user-defined validator
    Detailed: bool option
}

// 3. Build a registry once with the codecs your records use.
let codecs =
    emptyRegistry
    |> register Days.JsonCodec
    |> registerAll                    // NonEmptyString, PositiveInt, Email, Url, ...

// 4. Derive the codec and decode. Errors accumulate across all fields.
let codec = autoWith<WeatherRequest> codecs

match codec.decode jsonMap with
| Ok req -> handle req
| Error errs ->
    // [{ path = "days";     message = "must be > 0" };
    //  { path = "location"; message = "must be non-empty" }]
    printfn "%s" (formatErrors errs)

Use the …With forms whenever a record has refined or custom-codec fields — plain auto uses an empty registry, so a NonEmptyString or Days field has no codec to dispatch through.

For one-off rules where a named wrapper type would be overkill, the same pipeline is the F# Annotated equivalent inline: Codec.int |> Codec.gt 0 |> Codec.le 14.

Available combinators: Codec.gt, lt, ge, le, minLength, maxLength, nonEmpty, pattern, refine, map, describe. All apply to any IJsonCodec<'T>.

Bundled refined types: NonEmptyString, PositiveInt, NonNegativeInt, Email, Url, Uuid — register them all with registerAll (after open Fable.TypedJson.Refined) or pick à la carte.

Errors

A single Error lists every per-field problem with a path, not just the first one:

Error [
    { path = "days";     message = "must be > 0" }
    { path = "location"; message = "must be non-empty" }
    { path = "contact";  message = "must match pattern '^[^\s@]+@[^\s@]+\.[^\s@]+$'" }
]

formatErrors turns the list into one human-readable string — handy for surfacing back to an LLM as a tool error, or to a user as a form-validation summary.

Case rules

Field names from F# reflection become JSON keys via a CaseRules setting on the codec. The default is LowerFirst (camelCase). Use withCaseRules to switch.

type Reading = { AirTemperature: float; WindSpeed: float }

let reading = { AirTemperature = 22.5; WindSpeed = 3.0 }

(auto<Reading> ()).encode reading
// {"airTemperature":22.5,"windSpeed":3.0}

(auto<Reading> () |> withCaseRules CaseRules.SnakeCase).encode reading
// {"air_temperature":22.5,"wind_speed":3.0}

// One-off override (rare — same codec, multiple JSON formats). This rebuilds
// the codec, so hoist `withCaseRules` if it is on a hot path:
codec.decodeWith CaseRules.SnakeCaseAllCaps map
Rule Input Output
None MyField MyField
LowerFirst (default) MyField myField
SnakeCase MyField my_field
SnakeCaseAllCaps MyField MY_FIELD
KebabCase MyField my-field
PascalCase my_field MyField

Names are normalized through PascalCase internally, so a rule produces the same output regardless of how a backend's reflection presents the F# name. Single-word fields look identical under either rule — multi-word names are the only place the difference shows.

Aliases and cross-field rules

type Range = { Start: int; Until: int }

let codec =
    auto<Range> ()
    |> alias "Until" "end"          // override the JSON key for one field
    |> withModel (fun r ->          // cross-field invariant (Pydantic @model_validator)
        if r.Start <= r.Until then Ok r
        else Error [ { path = ""; message = "start must precede end" } ])

alias flows through decode lookup, encode output, and the JSON Schema's properties / required keys. Field names passed to alias are normalized to PascalCase internally, so the same call works on every backend.

Type coercion

Built-in primitive codecs accept several source types — useful when JSON comes from LLM tool calls or shells where everything arrives as a string:

Target type Accepted sources
string string, int, float, bool
int int, float, string (parseable)
int64 int, float, string (parseable)
float float, int, string (parseable)
bool bool, string ("true" / "false")

Tagged discriminated unions

An F# DU decodes and encodes as {"type": "<case>", ...payload} — the Pydantic / OpenAPI discriminated-union convention, and the shape Anthropic's and OpenAI's message formats use.

type SearchInput = { Query: string; MaxResults: int }

type Tool =
    | Search of SearchInput
    | Ping

let codec = auto<Tool> () |> withCaseRules CaseRules.SnakeCase

codec.decode (parseRaw """{"type":"search","query":"hello","max_results":5}""")
// Ok (Search { Query = "hello"; MaxResults = 5 })

codec.encode Ping
// {"type":"ping"}

A single record-payload case flattens its fields alongside the discriminator; a fieldless case is just the discriminator. The case name goes through the codec's CaseRules, so ToolUse becomes "tool_use" under SnakeCase.

Two shapes are not supported in v1: a case with a non-record payload (Circle of float) and a case with multiple positional fields (At of int * int). Both are rejected when the codec is built, not when a document happens to select that case — a DU with a case that cannot round-trip is a broken codec either way, and finding out at construction beats finding out in production. Wrap the payload in a record, or register an IJsonCodec for the type.

JSON Schema generation

Like Pydantic's model_json_schema(), a single call walks the codec tree and emits a JSON Schema document — handy for OpenAPI specs, LLM tool definitions, or runtime introspection. Constraints from the combinators (minLength, pattern, gt, ...) flow into the right schema keywords.

type Account = { Username: NonEmptyString; Email: Email }

let codec = autoWith<Account> codecs
let schemaJson = jsonSchemaOfCodec codecs codec
// {
//   "type": "object",
//   "title": "Account",
//   "properties": {
//     "username": { "type": "string", "minLength": 1 },
//     "email":    { "type": "string", "pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$" }
//   },
//   "required": ["username", "email"]
// }

jsonSchemaOfCodec reads the codec's configured caseRules and any alias overrides, so the schema's property names always match the JSON the codec actually accepts and produces. (For a quick schema with no aliases or codec, jsonSchemaOf<'T> codecs caseRules takes the case rule explicitly.) jsonSchemaOf<Tool> on a DU emits a oneOf branch per case, each pinning its discriminator with const.

Validating dicts and maps

The codec is the whole API — there is no second, weaker path. Two shorthands cover the common sources, both going through the same walker and the same key derivation a default codec uses:

// Map<string, string> — LLM tool-call inputs, form fields, env vars, ...
// Every value arrives as a string; primitive coercion turns it into the
// record's declared type (see `Type coercion` above).
let toolInput = Map.ofList [ "location", "Oslo"; "days", "3"; "detailed", "true" ]

match validateMapWith<WeatherRequest> codecs toolInput with
| Ok req -> handle req
| Error errs -> printfn "%s" (formatErrors errs)

// Backend-native JSON map (a parsed jsx map / Python dict / ...)
match validateJsonWith<WeatherRequest> codecs (parseRaw """{"location":"Oslo","days":3}""") with
| Ok req -> handle req
| Error errs -> printfn "%s" (formatErrors errs)

dump is the encode-side counterpart, producing a backend-native map instead of a string. All of these build a plan per call — for anything repeated, build a codec once and reuse it.

Both shorthands resolve camelCase keys. The string map is the one input shape where you usually don't choose the spelling — an LLM sends back the snake_case parameter names your tool schema advertised, an HTML form sends its own field names — so it takes a case rule as well:

let toolArgs = Map.ofList [ "device_id", "dev-1"; "target_value", "42" ]

validateMapWithCaseRules<SetCapabilityInput> CaseRules.SnakeCase toolArgs

// or, for aliases / a registry / a model validator / repeated calls:
let codec = auto<SetCapabilityInput> () |> withCaseRules CaseRules.SnakeCase
codec.decodeStringMap toolArgs

Matching is strict, exactly as on the JSON path: under SnakeCase the decoder reads device_id and only device_id — a stray deviceId is a missing field, not a second accepted spelling.

How it compares

This isn't a "better than" claim — it's a fit-for-purpose claim. Pick what matches your needs.

vs. Thoth.Json

Thoth is the established F#/Fable JSON library and the closest neighbor. Both lean on F# reflection; the pivot is around what's idiomatic.

Thoth.Json Fable.TypedJson
Primary style Manual Decode.field "x" Decode.string decoders; Auto<'T> is opt-in Reflection-driven auto<'T> is the primary path
Per-type customization Pass "extra coders" alongside the decoder Define a wrapper DU's static JsonCodec member; register once
Constraint composition Compose decoders with andThen / custom code Pipeline combinators (gt, lt, minLength, ...) — direct Pydantic Annotated equivalent
Error mode Fail-fast (first error) Accumulating (all per-field errors at once)
JSON Schema generation Not built in jsonSchemaOf<'T>, from the same walk as decode/encode
Backends JS, Python (Thoth.Json 10+), .NET BEAM, Python, JS, .NET
Coercion Strict (types must match) "42" → int 42 etc. (a Strict mode is planned)
Maturity Years of production use, large user base New

If you want explicit hand-written decoders or a battle-tested option with broad third-party support, use Thoth. If your records are mostly straightforward and you want validation rules to live in the type, this library is the match.

vs. Fable.SimpleJson

SimpleJson sits one rung lower: it parses JSON into a recursive Json AST and lets you pattern-match. It's closer to "JSON.parse and inspect" than to "validate against a record schema."

Fable.SimpleJson Fable.TypedJson
Output Recursive Json AST you pattern-match on, plus reflection-based parseAs<'T> Validated F# record
Validation rules Whatever you write after parsing Encoded in the type via wrapper DUs and combinators
Errors JSON parse errors only Per-field validation errors with paths
JSON Schema generation Not built in Yes (constraint-aware)
Backends JS, .NET BEAM, Python, JS, .NET

If you want a low-level JSON AST to inspect or you need maximum control over decoding, use SimpleJson. If you want type-driven validation with Pydantic-like ergonomics, use this.

Performance

From just bench (BenchmarkDotNet, DefaultJob, .NET 10). Absolute figures are machine-specific; the ratios are the portable part.

Thoth's reflection-driven path on .NET is Newtonsoft-backed while this library's .NET shim is System.Text.Json-backed, so a head-to-head ratio mixes parser choice with codec design. Each is therefore also shown against its own parser — the only column that says anything about the decoder itself.

Flat 3-field record, decode Mean Allocated Over its own parser
System.Text.Json (raw) 153 ns 224 B
Thoth.Json.STJ (hand-written decoder) 442 ns 976 B 2.9×
Newtonsoft (raw) 467 ns 3,056 B
Fable.TypedJson (auto) 518 ns 1,072 B 3.4×
Thoth.Json.Net (Decode.Auto) 7,224 ns 8,785 B 15.5×

The 14× end-to-end gap on that pair decomposes exactly into 4.6× decoder × 3.1× parser. The honest claim is the first number: a ~4.6× advantage on the automatic path. The rest is Newtonsoft.

Two results worth stating plainly: auto allocates less per decode than raw Newtonsoft does, and lands within ~17% of a hand-written Thoth decoder while requiring no decoder at all.

Other fixtures Fable.TypedJson Thoth.Json.Net (Auto) vs. System.Text.Json
nested decode — 2 levels + record list 2.92 µs 34.56 µs 2.2×
flat encode 252 ns 8,679 ns 2.7×
nested encode 6.67 µs 46.58 µs 10.1×

Only the flat-decode table is parser-decomposed; these three are end-to-end and carry the same Newtonsoft caveat. Flat encode is the one place a reflection-driven codec beats a hand-written one — Thoth's manual encoder is 592 ns — because auto writes straight into the backend map instead of building an intermediate tree first.

Both libraries are measured amortized: Thoth's Auto caches its generated coders internally, and these numbers build the TypedJson<'T> codec once outside the measured loop, as you should.

Construction is the trade this design makes. Resolving a type costs ~193 µs (flat) to ~1.03 ms (nested), most of it emitting delegates via PreComputeRecordConstructor on the CLR — which is what buys the ~12× per-decode win. Break-even is about 30 decodes of the same type, so bind codecs at module level rather than per call.

Architecture

Two design axes, each independent:

  1. Staged resolution (vertical) — the library is a staged compiler. Building a codec walks typeof<'T> once and emits a tree of closures; decoding, encoding and describing then just run them. No reflection, no type-name comparison and no key derivation survives into the per-call path, at any depth. One walk produces all three faces, so they cannot disagree about a type's wire shape.
  2. Backend-agnostic core vs per-target shims (horizontal) — IJsonBackend abstracts the actual JSON parser and the native map type. Concrete shims ship for BEAM (jsx), Python (json), JavaScript (JSON.parse / JSON.stringify), and .NET (System.Text.Json).

Adding a target means implementing IJsonBackend in a new Fable.TypedJson.<Target> project plus a <Target>.Json convenience module; the core does not change.

Contributing

Prerequisites

  • .NET SDK 10 (the test projects target net10.0)
  • just (task runner)
  • BEAM target: Erlang/OTP and rebar3
  • Python target: uv (the venv pulls in fable-library)

Workflow

just restore        # dotnet tools (Fable, Paket, Fantomas) + Paket deps + uv venv
just build          # transpile core + each shim to Erlang, Python, JavaScript
just check          # type-check all five projects via `dotnet build`
just test           # run all four backend test suites from the same F# sources
just format         # Fantomas over src/ and test/

Per-target variants exist for each: just build-beam / build-python / build-js, and just test-beam / test-python / test-js / test-dotnet.

Paket deps are split into five groups (Main, Beam, Python, JS, DotNet) so each backend project pulls in only what it needs — see paket.dependencies.

The same F# test sources compile to all four targets via #if PYTHON | JS | DOTNET blocks that swap a few backend-specific imports. Tests are written with Scriptorium — Quill for the test DSL and runner, Nib for assertions — both of which compile to every target, so a single runTests entry point in Main.fs replaces the per-target runners. Quill exits non-zero on failure on all four targets, so CI gates on it. Known per-target divergences are marked with Quill's skipIfJavaScript / skipIfDotNet configurers next to the test, each carrying a comment explaining the gap, so they show up as skips rather than silently disappearing.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (7)

Showing the top 5 NuGet packages that depend on Fable.TypedJson:

Package Downloads
Fable.TypedJson.JS

JavaScript backend shim for Fable.TypedJson. Wires `JSON.parse` / `JSON.stringify` and JavaScript's native object/array/number/string/boolean/null types into the IJsonBackend abstraction.

Fable.TypedJson.DotNet

.NET backend shim for Fable.TypedJson. Wires System.Text.Json into the IJsonBackend abstraction so the same codecs run natively on the CLR (alongside the Fable BEAM/JS/Python targets).

Fable.TypedJson.Beam

BEAM (Erlang) backend shim for Fable.TypedJson. Wires `Fable.Beam.Maps` and `Fable.Beam.Jsx.Jsx` into the IJsonBackend abstraction.

Fable.TypedJson.Python

Python backend shim for Fable.TypedJson. Wires Python's `json` stdlib into the IJsonBackend abstraction; wraps native ints / floats as Fable's int32 / float64 at the read boundary so erased JsonValue patterns dispatch correctly.

Fable.Giraffe.Js

Giraffe for Fable JavaScript (Node)

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
5.1.0 58 8/4/2026
5.0.1 97 8/4/2026
5.0.0 105 8/4/2026
5.0.0-rc.1 64 8/2/2026
0.4.1 81 8/1/2026
0.4.0 232 5/21/2026
0.3.0 214 5/3/2026
0.2.0 146 5/3/2026
0.2.0-rc.1 72 5/1/2026