Fable.TypedJson.Python
5.1.0
dotnet add package Fable.TypedJson.Python --version 5.1.0
NuGet\Install-Package Fable.TypedJson.Python -Version 5.1.0
<PackageReference Include="Fable.TypedJson.Python" Version="5.1.0" />
<PackageVersion Include="Fable.TypedJson.Python" Version="5.1.0" />
<PackageReference Include="Fable.TypedJson.Python" />
paket add Fable.TypedJson.Python --version 5.1.0
#r "nuget: Fable.TypedJson.Python, 5.1.0"
#:package Fable.TypedJson.Python@5.1.0
#addin nuget:?package=Fable.TypedJson.Python&version=5.1.0
#tool nuget:?package=Fable.TypedJson.Python&version=5.1.0
Fable.TypedJson
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:
- 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. - Backend-agnostic core vs per-target shims (horizontal) —
IJsonBackendabstracts 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 infable-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 | Versions 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. |
-
.NETStandard 2.0
- Fable.Core (>= 5.0.0)
- Fable.Python (>= 5.1.0)
- Fable.TypedJson (>= 5.1.0)
- FSharp.Core (>= 11.0.100)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Fable.TypedJson.Python:
| Package | Downloads |
|---|---|
|
Fable.Giraffe.Python
Giraffe for Fable Python |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 5.1.0 | 36 | 8/4/2026 |
| 5.0.0 | 50 | 8/4/2026 |
| 5.0.0-rc.1 | 47 | 8/2/2026 |
| 0.4.0 | 117 | 5/21/2026 |
| 0.3.0 | 116 | 5/3/2026 |
| 0.2.0-rc.1 | 63 | 5/1/2026 |