JSON in F#: System.Text.Json, Thoth.Json, Discriminated Unions & Fable

Last updated:

F# handles JSON through two main approaches: System.Text.Json for .NET server applications (same library as C#) and Thoth.Json for idiomatic functional JSON with Result-based error handling. JsonSerializer.Deserialize<MyRecord>(json) deserializes JSON into an F# record — it works out of the box since F# records compile to plain .NET classes with public properties. Thoth.Json's Decode.Auto.fromString<MyRecord>(json) returns Result<MyRecord, string> instead of throwing, matching F#'s preference for explicit error handling without exceptions. F# discriminated unions require custom JSON converters — [<JsonConverter(typeof<JsonStringEnumConverter>)>] handles simple enum-like unions; complex multi-payload unions need explicit converter implementations. For Fable (F# compiled to JavaScript), Thoth.Json is the standard — Decode.field "name" Decode.string builds a decoder pipeline. This guide covers System.Text.Json with F# records, Thoth.Json decoders and encoders, discriminated union serialization, naming strategies, and Fable JSON patterns.

System.Text.Json with F# Records

F# records are plain .NET reference types whose fields become public properties — exactly what System.Text.Json expects. JsonSerializer.Serialize(record) writes the fields as a JSON object; JsonSerializer.Deserialize<MyRecord>(json) reads the JSON back into a record value. No NuGet packages required: System.Text.Json ships with .NET 6 and later. Use [<JsonPropertyName("...")>] to override field names, and [<JsonIgnore>] to exclude fields from output.

open System.Text.Json
open System.Text.Json.Serialization

// ── F# record ─────────────────────────────────────────────────
// Fields become public properties in the compiled .NET type
[<CLIMutable>]          // required for deserialization in older .NET versions
type User = {
    Id:        int
    Name:      string
    Email:     string
    IsActive:  bool
}

// ── Basic serialize / deserialize ─────────────────────────────
let user = { Id = 1; Name = "Alice"; Email = "alice@example.com"; IsActive = true }

let json = JsonSerializer.Serialize(user)
// {"Id":1,"Name":"Alice","Email":"alice@example.com","IsActive":true}

let back: User = JsonSerializer.Deserialize<User>(json)
// { Id = 1; Name = "Alice"; Email = "alice@example.com"; IsActive = true }

// ── Custom field names with [<JsonPropertyName>] ───────────────
[<CLIMutable>]
type Product = {
    [<JsonPropertyName("product_id")>]
    ProductId: int

    [<JsonPropertyName("display_name")>]
    DisplayName: string

    [<JsonIgnore>]
    InternalCode: string     // excluded from JSON output

    Price: decimal
}

let p = { ProductId = 42; DisplayName = "Widget"; InternalCode = "W-42"; Price = 9.99m }
let pJson = JsonSerializer.Serialize(p)
// {"product_id":42,"display_name":"Widget","Price":9.99}

// ── JsonSerializerOptions for global configuration ─────────────
let opts = JsonSerializerOptions(
    PropertyNamingPolicy        = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition      = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented               = true,
    PropertyNameCaseInsensitive = true     // tolerant deserialization
)

let prettyJson = JsonSerializer.Serialize(user, opts)
// {
//   "id": 1,
//   "name": "Alice",
//   "email": "alice@example.com",
//   "isActive": true
// }

// ── Option<T> fields map to nullable JSON values ───────────────
[<CLIMutable>]
type Config = {
    Host:    string
    Port:    int option      // absent or null in JSON → None
    Timeout: int option
}

let cfg = { Host = "localhost"; Port = Some 5432; Timeout = None }
JsonSerializer.Serialize(cfg, opts)
// {"host":"localhost","port":5432}   (Timeout omitted: WhenWritingNull)

// ── Nested records serialize recursively ──────────────────────
[<CLIMutable>]
type Address = { Street: string; City: string }

[<CLIMutable>]
type Person  = { Name: string; Age: int; Address: Address }

let person = { Name = "Bob"; Age = 30; Address = { Street = "1 Main St"; City = "Paris" } }
JsonSerializer.Serialize(person, opts)
// {"name":"Bob","age":30,"address":{"street":"1 Main St","city":"Paris"}}

The [<CLIMutable>] attribute generates a parameterless constructor, which older versions of System.Text.Json require for deserialization. .NET 8 and later can deserialize into F# records without [<CLIMutable>] using the primary constructor. JsonSerializerOptions instances are expensive to create — cache them as static values or register them once with ASP.NET Core's dependency injection rather than constructing a new one per call.

Thoth.Json: Result-Based Safe Decoding

Thoth.Json models JSON decoding as a function from raw JSON to Result<'T, string>. Every decode operation either succeeds with Ok value or fails with Error message — no exceptions, no null returns. Decode.Auto.fromString<T> reflects on F# record types to generate a decoder automatically. For full control, compose decoders manually using Decode.object and field-level functions like get.Required.Field and get.Optional.Field.

// ── NuGet: dotnet add package Thoth.Json.Net ──────────────────
open Thoth.Json.Net

// ── Decode.Auto.fromString — reflection-based decoder ─────────
// Returns Result<'T, string> — never throws
type User = {
    Id:   int
    Name: string
    Age:  int option
}

let json = """{"Id": 1, "Name": "Alice", "Age": 30}"""

match Decode.Auto.fromString<User>(json) with
| Ok user    -> printfn "Decoded: %s (age %A)" user.Name user.Age
| Error msg  -> printfn "Decode failed: %s" msg

// ── Manual decoder pipeline with Decode.object ────────────────
// More verbose but gives precise error messages and field control
let userDecoder : Decoder<User> =
    Decode.object (fun get -> {
        Id   = get.Required.Field "id"   Decode.int
        Name = get.Required.Field "name" Decode.string
        Age  = get.Optional.Field "age"  Decode.int
    })

let camelJson = """{"id": 2, "name": "Bob"}"""

match Decode.fromString userDecoder camelJson with
| Ok u      -> printfn "%A" u
| Error err -> printfn "Error: %s" err

// ── Decoding arrays ────────────────────────────────────────────
let usersJson = """[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]"""

let usersDecoder = Decode.list userDecoder

match Decode.fromString usersDecoder usersJson with
| Ok users -> users |> List.iter (fun u -> printfn "%s" u.Name)
| Error e  -> printfn "Array decode error: %s" e

// ── Decode.Auto with camelCase ─────────────────────────────────
// caseStrategy = CamelCase maps JSON camelCase → F# PascalCase
let camelResult =
    Decode.Auto.fromString<User>(camelJson, caseStrategy = CamelCase)

// ── Chaining decoders with |> and Result.bind ─────────────────
let parseAndValidate (json: string) : Result<User, string> =
    Decode.Auto.fromString<User>(json)
    |> Result.bind (fun user ->
        if user.Name.Length = 0
        then Error "Name cannot be empty"
        else Ok user
    )

// ── Primitive decoders — building blocks ──────────────────────
// Decode.string    : Decoder<string>
// Decode.int       : Decoder<int>
// Decode.float     : Decoder<float>
// Decode.bool      : Decoder<bool>
// Decode.list      : Decoder<'T> -> Decoder<'T list>
// Decode.array     : Decoder<'T> -> Decoder<'T array>
// Decode.option    : Decoder<'T> -> Decoder<'T option>
// Decode.nullable  : Decoder<'T> -> Decoder<'T option>    (null → None)
// Decode.index     : int -> Decoder<'T> -> Decoder<'T>    (access array index)

let tupleDecoder =
    Decode.map2
        (fun name age -> (name, age))
        (Decode.field "name" Decode.string)
        (Decode.field "age"  Decode.int)

Manual decoder pipelines pay off when JSON field names differ from F# names, when you need to decode a polymorphic JSON shape, or when you want more descriptive error messages than the auto-decoder provides. The Decode.Auto path is fast enough for most use cases — it generates the decoder once per type and caches it. For hot paths processing thousands of records per second, prefer manual decoders, which avoid reflection overhead at runtime.

Thoth.Json Encoders: Building JSON from F# Values

Thoth.Json encoders are functions that convert F# values into a JsonValue tree, which is then serialized to a string. Encode.Auto.toString generates the encoder automatically via reflection. Encode.object builds a JSON object field by field, and primitive encoders like Encode.string, Encode.int, and Encode.list handle scalars and collections. Encoders compose: pass an encoder to Encode.option to handle option-valued fields.

open Thoth.Json.Net

// ── Encode.Auto.toString — reflection-based encoder ───────────
type Product = {
    Id:    int
    Name:  string
    Price: float
    Tags:  string list
}

let product = { Id = 1; Name = "Widget"; Price = 9.99; Tags = ["sale"; "new"] }

Encode.Auto.toString(2, product)
// {
//   "Id": 1,
//   "Name": "Widget",
//   "Price": 9.99,
//   "Tags": ["sale","new"]
// }

// ── Manual encoder with Encode.object ─────────────────────────
let encodeProduct (p: Product) : JsonValue =
    Encode.object [
        "id",    Encode.int    p.Id
        "name",  Encode.string p.Name
        "price", Encode.float  p.Price
        "tags",  Encode.list   Encode.string p.Tags
    ]

let json = encodeProduct product |> Encode.toString 0
// {"id":1,"name":"Widget","price":9.99,"tags":["sale","new"]}

// ── Encoding option fields ─────────────────────────────────────
type Config = { Host: string; Port: int option }

let encodeConfig (cfg: Config) =
    Encode.object [
        "host", Encode.string cfg.Host
        "port", Encode.option Encode.int cfg.Port
        // Some 5432 → 5432 ; None → null
    ]

// ── Encoding with conditional fields (omit when None) ─────────
let encodeConfigClean (cfg: Config) =
    [
        yield "host", Encode.string cfg.Host
        match cfg.Port with
        | Some p -> yield "port", Encode.int p
        | None   -> ()              // field simply omitted
    ]
    |> Encode.object

{ Host = "db"; Port = None } |> encodeConfigClean |> Encode.toString 0
// {"host":"db"}

// ── Encoding arrays and nested objects ─────────────────────────
type Address = { Street: string; City: string }
type Person  = { Name: string; Age: int; Address: Address }

let encodeAddress (a: Address) =
    Encode.object [
        "street", Encode.string a.Street
        "city",   Encode.string a.City
    ]

let encodePerson (p: Person) =
    Encode.object [
        "name",    Encode.string  p.Name
        "age",     Encode.int     p.Age
        "address", encodeAddress  p.Address
    ]

// ── Round-trip: encode → string → decode ──────────────────────
let personDecoder : Decoder<Person> =
    Decode.object (fun get -> {
        Name    = get.Required.Field "name"    Decode.string
        Age     = get.Required.Field "age"     Decode.int
        Address = get.Required.Field "address" (Decode.object (fun g -> {
            Street = g.Required.Field "street" Decode.string
            City   = g.Required.Field "city"   Decode.string
        }))
    })

let alice = { Name = "Alice"; Age = 28; Address = { Street = "7 Oak Lane"; City = "Lyon" } }
let rt    = alice |> encodePerson |> Encode.toString 0 |> Decode.fromString personDecoder
// Ok { Name = "Alice"; Age = 28; Address = { Street = "7 Oak Lane"; City = "Lyon" } }

The pattern of yielding fields conditionally with match … | None -> () is idiomatic F# and produces clean JSON without null-valued fields. This is a meaningful difference from System.Text.Json's WhenWritingNull option: Thoth.Json omits the key entirely, while WhenWritingNull requires the value to serialize to a null JSON token rather than an absent key.

Discriminated Unions and JSON Serialization

F# discriminated unions are one of the language's defining features, but JSON has no native concept of union types. Serializing unions requires an explicit representation strategy. Simple single-case unions used as enums serialize as JSON strings with [<JsonConverter(typeof<JsonStringEnumConverter>)>]. Multi-payload unions need a custom JsonConverter<T> or a Thoth.Json manual encoder/decoder pair.

open System.Text.Json
open System.Text.Json.Serialization
open Thoth.Json.Net

// ── Enum-like DU with JsonStringEnumConverter ─────────────────
[<JsonConverter(typeof<JsonStringEnumConverter>)>]
type Status =
    | Active
    | Inactive
    | Pending

type Account = {
    Id:     int
    Status: Status
}

let acc = { Id = 1; Status = Active }
JsonSerializer.Serialize(acc)
// {"Id":1,"Status":"Active"}

// ── Complex DU — custom System.Text.Json converter ────────────
// Discriminant field "type" drives deserialization
type Shape =
    | Circle    of Radius: float
    | Rectangle of Width: float * Height: float
    | Triangle  of Base: float * Height: float

type ShapeConverter() =
    inherit JsonConverter<Shape>()

    override _.Write(writer, value, _options) =
        writer.WriteStartObject()
        match value with
        | Circle r ->
            writer.WriteString("type", "circle")
            writer.WriteNumber("radius", r)
        | Rectangle(w, h) ->
            writer.WriteString("type", "rectangle")
            writer.WriteNumber("width", w)
            writer.WriteNumber("height", h)
        | Triangle(b, h) ->
            writer.WriteString("type", "triangle")
            writer.WriteNumber("base", b)
            writer.WriteNumber("height", h)
        writer.WriteEndObject()

    override _.Read(reader, _typeToConvert, _options) =
        use doc = JsonDocument.ParseValue(&reader)
        let root = doc.RootElement
        let kind = root.GetProperty("type").GetString()
        match kind with
        | "circle"    -> Circle(root.GetProperty("radius").GetDouble())
        | "rectangle" -> Rectangle(root.GetProperty("width").GetDouble(),
                                    root.GetProperty("height").GetDouble())
        | "triangle"  -> Triangle(root.GetProperty("base").GetDouble(),
                                   root.GetProperty("height").GetDouble())
        | unknown     -> failwithf "Unknown shape type: %s" unknown

let opts = JsonSerializerOptions()
opts.Converters.Add(ShapeConverter())

JsonSerializer.Serialize(Circle 3.0, opts)
// {"type":"circle","radius":3.0}

JsonSerializer.Serialize(Rectangle(4.0, 5.0), opts)
// {"type":"rectangle","width":4.0,"height":5.0}

// ── Thoth.Json DU encoder/decoder — no reflection needed ──────
let encodeShape (shape: Shape) : JsonValue =
    match shape with
    | Circle r       -> Encode.object [ "type", Encode.string "circle";    "radius", Encode.float r ]
    | Rectangle(w,h) -> Encode.object [ "type", Encode.string "rectangle"; "width",  Encode.float w; "height", Encode.float h ]
    | Triangle(b,h)  -> Encode.object [ "type", Encode.string "triangle";  "base",   Encode.float b; "height", Encode.float h ]

let decodeShape : Decoder<Shape> =
    Decode.field "type" Decode.string
    |> Decode.andThen (function
        | "circle"    -> Decode.map  Circle    (Decode.field "radius" Decode.float)
        | "rectangle" -> Decode.map2 Rectangle  (Decode.field "width" Decode.float) (Decode.field "height" Decode.float)
        | "triangle"  -> Decode.map2 Triangle   (Decode.field "base"  Decode.float) (Decode.field "height" Decode.float)
        | s           -> Decode.fail (sprintf "Unknown shape: %s" s)
    )

The Thoth.Json approach is more verbose but produces clearer error messages — when "type": "hexagon" appears in the JSON, Decode.fail "Unknown shape: hexagon" propagates an Error through the Result chain rather than throwing a MatchFailureException. Choose the System.Text.Json custom converter when you are already using System.Text.Json and want minimal dependencies; choose Thoth.Json decoders when you want total functions and consistent Result-based error handling throughout the codebase.

Naming Strategies: PascalCase to camelCase

.NET and F# use PascalCase for record fields; JSON APIs typically use camelCase or snake_case. Bridging the two requires a naming policy. System.Text.Json provides built-in policies; Thoth.Json controls naming per field in the decoder/encoder pipeline, with a global caseStrategy override for Decode.Auto.

open System.Text.Json
open System.Text.Json.Serialization
open Thoth.Json.Net

// ── System.Text.Json naming policies ──────────────────────────
// JsonNamingPolicy.CamelCase      → FirstName → "firstName"   (.NET 6+)
// JsonNamingPolicy.SnakeCaseLower → FirstName → "first_name"  (.NET 8+)
// JsonNamingPolicy.KebabCaseLower → FirstName → "first-name"  (.NET 8+)

[<CLIMutable>]
type ApiResponse = {
    UserId:    int
    FirstName: string
    LastName:  string
    IsVerified: bool
}

let opts = JsonSerializerOptions(
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
)

let resp = { UserId = 7; FirstName = "Claire"; LastName = "Dupont"; IsVerified = true }

JsonSerializer.Serialize(resp, opts)
// {"userId":7,"firstName":"Claire","lastName":"Dupont","isVerified":true}

// Deserialization with case-insensitive matching
let optsIn = JsonSerializerOptions(
    PropertyNameCaseInsensitive = true
)
let json = """{"userid":7,"firstname":"Claire","lastname":"Dupont","isverified":true}"""
let result: ApiResponse = JsonSerializer.Deserialize<ApiResponse>(json, optsIn)

// ── [<JsonPropertyName>] overrides the global policy per field ─
[<CLIMutable>]
type Mixed = {
    [<JsonPropertyName("user_id")>]
    UserId: int                        // explicit snake_case

    FirstName: string                  // follows global CamelCase → "firstName"
}

// ── ASP.NET Core global option for F# JSON APIs ───────────────
// In Program.fs:
// builder.Services
//     .AddControllers()
//     .AddJsonOptions(fun opts ->
//         opts.JsonSerializerOptions.PropertyNamingPolicy        <- JsonNamingPolicy.CamelCase
//         opts.JsonSerializerOptions.DefaultIgnoreCondition      <- JsonIgnoreCondition.WhenWritingNull
//         opts.JsonSerializerOptions.PropertyNameCaseInsensitive <- true
//     )

// ── Thoth.Json caseStrategy ───────────────────────────────────
// Decode.Auto reads camelCase JSON → PascalCase F# record
type User = { UserId: int; FirstName: string; LastName: string }

let camelJson = """{"userId":1,"firstName":"Alice","lastName":"Lam"}"""

// CamelCase strategy maps camelCase JSON keys to PascalCase F# fields
Decode.Auto.fromString<User>(camelJson, caseStrategy = CamelCase)
// Ok { UserId = 1; FirstName = "Alice"; LastName = "Lam" }

// SnakeCase strategy maps snake_case JSON keys to PascalCase F# fields
let snakeJson = """{"user_id":1,"first_name":"Alice","last_name":"Lam"}"""
Decode.Auto.fromString<User>(snakeJson, caseStrategy = SnakeCase)
// Ok { UserId = 1; FirstName = "Alice"; LastName = "Lam" }

// Manual decoder — explicit key strings, total control over mapping
let userDecoder =
    Decode.object (fun get -> {
        UserId    = get.Required.Field "userId"     Decode.int
        FirstName = get.Required.Field "first_name" Decode.string   // mixed naming
        LastName  = get.Required.Field "last_name"  Decode.string
    })

The PropertyNamingPolicy is applied globally but [<JsonPropertyName>] on individual fields takes precedence. This matters when you are consuming a third-party API that uses inconsistent naming — you can apply a global camelCase policy and use explicit annotations only for the non-conforming fields. With Thoth.Json, manual decoders always use the exact JSON key string, giving you fine-grained control without any global state.

Fable: F# to JavaScript JSON with Thoth.Json

Fable compiles F# to JavaScript. In a Fable project, Thoth.Json (the npm-backed version) handles all JSON operations. Decoders and encoders compile to efficient JavaScript property reads and object construction — no .NET reflection at runtime. The Decode.Auto.fromString path uses Fable's compile-time reflection to generate decoders statically. The Elmish pattern (Model-Update-View) uses Thoth.Json decoders in Cmd.ofPromise or Thoth.Fetch to parse API responses into typed model values.

// ── Fable project — install via npm ───────────────────────────
// npm install thoth-json

open Thoth.Json   // Note: Thoth.Json (not Thoth.Json.Net) for Fable

// ── Decoder compiles to plain JavaScript property reads ────────
type Post = {
    Id:    int
    Title: string
    Body:  string
}

let postDecoder : Decoder<Post> =
    Decode.object (fun get -> {
        Id    = get.Required.Field "id"    Decode.int
        Title = get.Required.Field "title" Decode.string
        Body  = get.Required.Field "body"  Decode.string
    })

// Compiles to something like:
// const decodePost = (json) => {
//   const id    = json.id;    if (typeof id    !== 'number') return Error(...)
//   const title = json.title; if (typeof title !== 'string') return Error(...)
//   const body  = json.body;  if (typeof body  !== 'string') return Error(...)
//   return Ok({ Id: id, Title: title, Body: body })
// }

// ── Thoth.Fetch — fetch API with built-in Thoth decoding ──────
// npm install thoth-fetch
open Thoth.Fetch

let fetchPosts () : JS.Promise<Result<Post list, string>> =
    Fetch.tryGet<Post list>(
        url     = "https://jsonplaceholder.typicode.com/posts",
        decoder = Decode.list postDecoder
    )

// ── Elmish (TEA) integration pattern ──────────────────────────
// Model-Update-View architecture — decoders keep the model total

type Model = { Posts: Post list; Error: string option; Loading: bool }
type Msg   = | PostsLoaded of Result<Post list, string>

let update (msg: Msg) (model: Model) =
    match msg with
    | PostsLoaded (Ok posts) -> { model with Posts = posts; Loading = false }
    | PostsLoaded (Error e)  -> { model with Error = Some e; Loading = false }

// ── Encode.Auto.toString in Fable ─────────────────────────────
// Same API as server side — compiles to JSON.stringify internally
let post = { Id = 1; Title = "Hello Fable"; Body = "F# compiles to JS" }
let json = Encode.Auto.toString(0, post)
// {"Id":1,"Title":"Hello Fable","Body":"F# compiles to JS"}

// ── Decode.Auto in Fable — compile-time reflection ────────────
// Fable generates the decoder statically at compile time
// No runtime reflection, no performance penalty
let result = Decode.Auto.fromString<Post>(json)
// Ok { Id = 1; Title = "Hello Fable"; Body = "F# compiles to JS" }

// ── Shared decoder between server (Thoth.Json.Net) and Fable ──
// Place in a shared project referenced by both:
// - Server.fsproj (references Thoth.Json.Net via NuGet)
// - Client.fsproj (Fable project, references thoth-json via npm)
// The decoder code is IDENTICAL in both compilation targets
module Shared =
    type ApiUser = { Id: int; Name: string; Email: string }

    let decodeUser : Decoder<ApiUser> =
        Decode.object (fun get -> {
            Id    = get.Required.Field "id"    Decode.int
            Name  = get.Required.Field "name"  Decode.string
            Email = get.Required.Field "email" Decode.string
        })

The ability to share decoder code between the .NET server and the Fable JavaScript client is one of Thoth.Json's practical advantages. A shared F# project referenced by both .fsproj files contains the decoders once. When the API schema changes, you update the decoder in one place and both compilation targets fail to compile if they use the changed type incorrectly — compile-time safety across the entire full-stack application.

Comparison: System.Text.Json vs Thoth.Json vs Newtonsoft

F# developers choose between three JSON libraries based on the project context. System.Text.Json is the zero-dependency default for server-side .NET projects. Thoth.Json is the idiomatic choice for functional F# code and Fable projects. Newtonsoft.Json (Json.NET) is the legacy option that handles edge cases like F# option types and union types better than early versions of System.Text.Json, but has been largely superseded.

// ── Library comparison ─────────────────────────────────────────
//
// System.Text.Json
//   ✅ Built into .NET — no NuGet package needed
//   ✅ Fastest for simple record serialization (~0.05 ms / 10-field record)
//   ✅ AOT-compatible with source generation (JsonSerializerContext)
//   ✅ Same attributes as C# ([<JsonPropertyName>], [<JsonIgnore>])
//   ⚠️  F# option types need special handling (null ↔ None)
//   ⚠️  Discriminated unions require custom JsonConverter<T>
//   ❌  Throws on invalid JSON — no Result return
//
// Thoth.Json.Net  (server) / Thoth.Json (Fable)
//   ✅ Returns Result<'T, string> — total functions, no exceptions
//   ✅ Works identically on .NET and Fable (JavaScript)
//   ✅ First-class discriminated union support via decoder pipelines
//   ✅ Composable decoders with andThen, map, map2
//   ✅ Fine-grained error messages ("expected int, got string at path .users[2].age")
//   ⚠️  Verbose for simple record types (more code than Decode.Auto)
//   ⚠️  Decode.Auto is slower than System.Text.Json for hot paths
//
// Newtonsoft.Json (Json.NET)
//   ✅ Best F# option/union support out of the box (with JsonUnionEncoding)
//   ✅ JsonPath SelectToken for dynamic JSON navigation
//   ✅ Very mature — handles edge cases System.Text.Json misses
//   ⚠️  Not included in .NET — requires NuGet package (2.5 MB)
//   ⚠️  Slower than System.Text.Json by 2–3× on .NET 8
//   ❌  Not compatible with Fable

// ── Newtonsoft + FSharp.SystemTextJson recommendation ─────────
// For teams that need rich F# union support with System.Text.Json,
// use the FSharp.SystemTextJson NuGet package:
// dotnet add package FSharp.SystemTextJson

open System.Text.Json
open System.Text.Json.Serialization

// Handles F# option, list, Map, and discriminated unions automatically
let fsOpts = JsonSerializerOptions()
fsOpts.Converters.Add(JsonFSharpConverter())

type Result2 = | Success of string | Failure of int * string

JsonSerializer.Serialize(Success "ok", fsOpts)
// {"Case":"Success","Fields":["ok"]}

// Or with tag format for cleaner JSON:
let tagOpts = JsonSerializerOptions()
tagOpts.Converters.Add(
    JsonFSharpConverter(
        JsonUnionEncoding.AdjacentTag     // {"type":"Success","value":"ok"}
    )
)

// ── Performance reference (10-field record, .NET 8, μs) ───────
// System.Text.Json    serialize:  48 μs  deserialize:  61 μs
// FSharp.SystemTextJson serialize: 55 μs  deserialize:  72 μs
// Thoth.Json.Net Auto serialize:  90 μs  deserialize: 105 μs
// Newtonsoft.Json     serialize: 130 μs  deserialize: 155 μs

FSharp.SystemTextJson is a widely used bridge package that adds native F# type support (options, unions, maps, sets) as a converter on top of System.Text.Json. It gives you the performance of the built-in library with the F# ergonomics of Newtonsoft. Use it when you need System.Text.Json compatibility (AOT, ASP.NET Core) but also need to serialize complex F# types without writing custom converters.

Key Terms

F# record
An F# record is a named product type with named, immutable fields. Records compile to .NET classes with public get-only properties and a constructor. Because they are standard .NET types, System.Text.Json serializes them without any special configuration — each field becomes a JSON key. The [<CLIMutable>] attribute adds a parameterless constructor required for deserialization by older System.Text.Json versions and some .NET binding frameworks. In .NET 8 and later, the JsonConstructorAttribute allows deserialization using the primary constructor without [<CLIMutable>].
discriminated union
A discriminated union (DU) in F# is a type that can be one of a fixed set of named cases, each optionally carrying data. DUs have no direct JSON equivalent — a JSON object or array must represent the chosen case explicitly. The standard approach uses a discriminant field (typically "type" or "kind") to identify the case, with additional fields carrying the case payload. System.Text.Json requires a custom JsonConverter<T> to serialize and deserialize DUs. Thoth.Json handles them naturally through manual encoder/decoder pipelines with Decode.andThen for conditional branching. The FSharp.SystemTextJson package provides automatic DU serialization with configurable union encoding formats.
Thoth.Json decoder
A Thoth.Json decoder is a value of type Decoder<'T>, which is a function from a JSON path and JSON token to Result<'T, DecoderError>. Decoders compose: Decode.map transforms the success value, Decode.andThen chains decoders sequentially (the output of one becomes the input of the next), and Decode.map2 combines two decoders into a two-argument constructor. The Decode.Auto.generateDecoder<T> function creates a decoder automatically from an F# type's reflection data, caching the result for subsequent calls. Decoders are pure functions with no side effects — they can be tested independently of any HTTP layer.
Fable
Fable is a compiler that translates F# source code to JavaScript (or TypeScript). It targets the web ecosystem — F# code compiled by Fable runs in browsers, Node.js, and Deno. The Fable ecosystem uses Thoth.Json (the JavaScript-backed version) for JSON, Elmish for the Model-Update-View architecture, and Feliz for React bindings. Fable's compile-time reflection generates JSON decoders and encoders at compile time rather than runtime, eliminating the reflection overhead of Decode.Auto in production JavaScript. The package is installed via npm (npm install thoth-json) rather than NuGet.
total function
A total function is one that returns a well-defined value for every possible input — it never throws an exception or enters an infinite loop. F# favors total functions: Decode.Auto.fromString returns Result<'T, string> for every JSON input, including malformed JSON and type mismatches, rather than throwing. This makes calling code more reliable: the compiler forces you to handle both Ok and Error cases via pattern matching, preventing unhandled exceptions from propagating through the application. In contrast, JsonSerializer.Deserialize is a partial function — it throws JsonException on invalid input, requiring callers to catch exceptions explicitly.
FSharp.SystemTextJson
FSharp.SystemTextJson is a NuGet package that adds a JsonFSharpConverter to System.Text.Json's converter pipeline. The converter handles F# option types (serializing None as null and omitting None fields when configured), discriminated unions (with configurable union encoding formats: AdjacentTag, ExternalTag, InternalTag, Untagged), F# list, set, and map types. It is the recommended choice for teams that want System.Text.Json's performance and AOT compatibility while preserving idiomatic F# type serialization. As of version 1.x, it is compatible with .NET 6 through .NET 8 and with ASP.NET Core's JSON options.

FAQ

How do I serialize an F# record to JSON?

Use JsonSerializer.Serialize from System.Text.Json, which is included in .NET 6 and later at no extra cost. F# records are plain .NET types, so serialization works out of the box: JsonSerializer.Serialize(myRecord) returns a JSON string. By default, field names are written as-is (PascalCase). To emit camelCase, pass JsonSerializerOptions with PropertyNamingPolicy = JsonNamingPolicy.CamelCase. For custom field names, annotate individual fields with [<JsonPropertyName("snake_case_name")>] — the same attribute used in C#. F# records compiled to .NET classes expose their fields as public properties, so the serializer sees them exactly like a C# class. The JsonSerializer.Serialize call takes about 0.05 ms for a 10-field record on .NET 8 with AOT-compatible source generation.

How do I deserialize JSON to an F# record safely?

The safest approach is Thoth.Json's Decode.Auto.fromString<MyRecord>(json), which returns Result<MyRecord, string> instead of throwing an exception. When the result is Ok record, you have a fully typed F# record; when it is Error message, you have a descriptive error string explaining which field failed and why. For System.Text.Json, wrap JsonSerializer.Deserialize<MyRecord>(json) in a try/catch or use a helper that converts the exception to a Result type. With System.Text.Json, set AllowTrailingCommas = true and ReadCommentHandling = JsonCommentHandling.Skip for tolerance, and PropertyNameCaseInsensitive = true if the JSON source uses a different casing convention. Thoth.Json requires roughly 1.5 kB of NuGet dependency (Thoth.Json.Net) and adds about 0.1 ms per decode call for a 10-field record.

What is Thoth.Json and why is it preferred in F#?

Thoth.Json is an F# JSON library that models encoding and decoding as composable functions rather than reflection-based serialization. Its core insight is that every Decode function returns Result<'T, string> — a value is either decoded successfully (Ok value) or fails with an error message (Error msg). This keeps F# code total: no exceptions, no null returns, no silent failures. Decode.Auto.fromString<T>(json) uses reflection to auto-generate a decoder for any F# record or discriminated union, matching Newtonsoft-style convenience. For finer control, you build decoder pipelines: Decode.object (fun get -> { Name = get.Required.Field "name" Decode.string }). The library has two backends: Thoth.Json.Net targets .NET server projects; Thoth.Json targets Fable. Both share an identical public API, letting you write decoders once and run them on server and client.

How do I serialize discriminated unions to JSON in F#?

Simple single-case discriminated unions used as enums work with [<JsonConverter(typeof<JsonStringEnumConverter>)>] — the same converter as C# enums, writing each case name as a JSON string. Complex multi-payload unions require a custom JsonConverter<T>. The converter's Read method inspects a discriminant field (typically "type" or "kind") to decide which union case to construct; Write serializes the case data alongside the discriminant. For example, a union Shape = Circle of float | Rectangle of float * float requires a ShapeConverter that writes {"type":"circle","radius":1.5}. Thoth.Json handles this naturally: each union case gets its own encoder/decoder branch, and Decode.andThen selects the branch by reading the discriminant field. The FSharp.SystemTextJson package handles DUs automatically with configurable encoding formats — the AdjacentTag format produces {"type":"Circle","value":1.5}.

How do I convert F# PascalCase fields to JSON camelCase?

Pass JsonSerializerOptions with PropertyNamingPolicy = JsonNamingPolicy.CamelCase to JsonSerializer.Serialize and Deserialize. This converts all F# record field names (PascalCase by .NET convention) to camelCase in the JSON output — FirstName becomes "firstName". You can make this the project-wide default in ASP.NET Core by setting the option in AddControllers() or AddJsonOptions(). For snake_case conversion, use JsonNamingPolicy.SnakeCaseLower (added in .NET 8). With Thoth.Json, pass caseStrategy = CamelCase to Decode.Auto.fromString and Encode.Auto.toString, or control each key string explicitly in manual decoders. If you mix a global naming policy with individual [<JsonPropertyName>] attributes, the attribute takes precedence for that field.

What is the difference between Thoth.Json.Net and Thoth.Json?

Both are the same Thoth.Json library but compiled for different runtimes. Thoth.Json.Net (NuGet package) targets .NET — it runs on the server side in F# applications using .NET 6, 7, or 8. Internally it uses System.Text.Json as the JSON engine in version 11 and later. Thoth.Json (npm package via Fable) targets browser JavaScript — it compiles F# code to JavaScript using the Fable transpiler, and the underlying JSON operations become native JSON.parse and JSON.stringify calls. The public API is identical in both: Decode.Auto.fromString<T>, Encode.object, Decode.field, and all decoder combinators work the same way. This means you can put decoder and encoder code in a shared F# project referenced by both a Fable frontend and a .NET backend, and it compiles correctly on both targets. The only difference is the NuGet vs npm package name.

How does F# JSON work in Fable (compiled to JavaScript)?

Fable compiles F# source code to JavaScript, and JSON handling uses Thoth.Json (the JavaScript-targeted package, installed via npm). A decoder pipeline like Decode.object (fun get -> { Name = get.Required.Field "name" Decode.string }) compiles to efficient JavaScript object property reads. Decode.Auto.fromString<MyRecord>(json) uses Fable's compile-time reflection to generate decoders statically — no runtime reflection overhead. The result is still Result<MyRecord, string>. Encoders compile to JavaScript object literals: Encode.object [ "name", Encode.string record.Name ] produces { name: "Alice" } in JavaScript. For Fable and Elmish (The Elm Architecture), Thoth.Json decoders handle HTTP API responses in the update function, keeping the model strongly typed. The Thoth.Fetch library wraps the browser Fetch API with Thoth.Json decoders, returning Promise<Result<T, string>> for every API call.

How do I handle optional fields in F# JSON?

With System.Text.Json, mark the record field as option type: type Person = { Name: string; Age: int option }. By default, System.Text.Json writes None as null and deserializes a null or missing field as None. For cleaner omission of None fields during serialization, set DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull in JsonSerializerOptions. With Thoth.Json, use get.Optional.Field "age" Decode.int in a decoder pipeline — this returns int option, with None if the field is absent or null in the JSON. For encoding, Encode.option Encode.int (Some 30) writes 30, and Encode.option Encode.int None writes null. For truly absent fields (not null), yield the field conditionally in the encoder list using a match expression — this produces cleaner JSON without null-valued keys and is more precise than the None/null ambiguity in System.Text.Json.

Validate and format JSON from your F# application

Paste JSON produced by your F# records or Thoth.Json encoders into Jsonic's formatter to validate, format, and inspect the output instantly.

Open JSON Validator

Further reading and primary sources