agentleFS
TheAngryByrdTheAngryByrd/fsharp-skills/skills/fsharp-serialization/SKILL.md

fsharp-serialization

Skill23 starsChanged 32 days agoMITView on GitHub

Serialize F# records, unions, options, and maps to JSON that matches a wire contract, and keep old JSON readable. Use when writing or reviewing F# code that calls System.Text.Json, configures FSharp.SystemTextJson (JsonFSharpConverter, JsonFSharpOptions), writes Thoth.Json encoders or decoders, adds a DTO layer, reads JSON from another service, or adds or renames a union case or record field that is stored or sent as JSON.

The file

We split this skill into the rules it gives an agent

10 rules. About 0 are common, 5 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Write the wire contract before you choose settings.

    line 15
  2. Very specific:

    Do not use System.Text.Json alone for F# domain types.

    line 16
  3. Very specific:

    Do not keep the FSharp.SystemTextJson defaults for a contract that you do not own.

    line 17
  4. Very specific:

    Do not use DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull for this.

    line 22
  5. Distinctive:

    When the wire shape differs from the domain types, or the domain types have private constructors, decode into a DTO or with a Thoth.Json …

    line 25
  6. Distinctive:

    Read references/SETTINGS.md for the System.Text.Json defaults, each FSharp.SystemTextJson union encoding with its output, and the option, …

    line 28
  7. Distinctive:

    Read references/WIRE-COMPATIBILITY.md before you add, rename, or remove a union case or record field that is stored or sent, and for the …

    line 29
  8. Distinctive:

    Write the wire contract as a list or table.

    line 33
  9. Very specific:

    Run the cases. Confirm that no output has a Case or Fields property unless the contract uses it, and that the decode function returns Error …

    line 38
  10. Distinctive:

    Run the proof script from the skill root:

    line 42

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

F# serialization

System.Text.Json was made for C# classes. With F# types it throws, writes a shape that other services cannot read, or reads missing data as null or 0 with no error. FSharp.SystemTextJson fixes the exceptions, but its defaults write a shape that other services do not use. A serializer that round-trips its own output can still disagree with the other service on every message.

Rules

  • Write the wire contract before you choose settings. For each type, record the property names, the union shape, what an absent or null optional value means, the map key shape, the required fields, and what to do with unknown properties and unknown cases.
  • Do not use System.Text.Json alone for F# domain types. It throws NotSupportedException on a union. It fills a missing record field with null or 0, and it reads the JSON null as a null record.
  • Do not keep the FSharp.SystemTextJson defaults for a contract that you do not own. The defaults write {"Case":...,"Fields":[...]}, reject a missing option field, and write Map<int, _> as an array of pairs.
  • Set each union encoding, the union tag name, and the union tag naming policy. PropertyNamingPolicy does not change union tags.
  • Encoding flags apply to every union. When one union needs a different shape, use WithOverrides. A [<JsonFSharpConverter>] attribute on a type has no effect when global options exist, unless you call WithAllowOverride.
  • To leave out None and read an absent or null value as None, use WithSkippableOptionFields(SkippableOptionFields.Always, deserializeNullAsNone = true). WithSkippableOptionFields() reads null in a string option as Some null. Do not use DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull for this. It reads a missing required string, Map, or union case field as null and throws NullReferenceException for a missing int.
  • A single-case union around an option (Box of int option) writes null for Box None and reads it back as a null union. An option of an option writes Some None as null. Change the type or write a converter.
  • At the decode boundary, return Error for a JsonException. When the code reads with System.Text.Json alone, into a DTO class, or into a single-case union around an option, also return Error for a null result. Run the cases that other exceptions can come from, and fix the settings that cause them.
  • When the wire shape differs from the domain types, or the domain types have private constructors, decode into a DTO or with a Thoth.Json decoder, then parse into the domain type. See parse-dont-validate.
  • For Native AOT and trimming, use fsharp-native-aot. This skill does not cover source generation.

Read references/SETTINGS.md for the System.Text.Json defaults, each FSharp.SystemTextJson union encoding with its output, and the option, map, and naming settings. Read references/WIRE-COMPATIBILITY.md before you add, rename, or remove a union case or record field that is stored or sent, and for the saved-sample tests, Thoth.Json, and DTO layers.

Steps

  1. Write the wire contract as a list or table. Done when each union, option, map, and required field has a rule.
  2. Get JSON samples that the other side wrote, or that the current code wrote before the change. Save them as files next to the tests.
  3. Choose FSharp.SystemTextJson settings, Thoth.Json coders, or a DTO layer. Make one options value for each wire contract, and use it for every call on that contract.
  4. For each saved sample, check that it decodes to the expected F# value, and that the expected value encodes to the same JSON. Compare parsed JSON, not text, so that property order and white space do not count.
  5. For each contract rule that no sample shows, add a case: an absent optional value, a null optional value, a missing required field, a null required field, an unknown property, an unknown union case, the JSON null, and malformed JSON.
  6. Run the cases. Confirm that no output has a Case or Fields property unless the contract uses it, and that the decode function returns Error and does not throw.

Verify

Run the proof script from the skill root:

pwsh ./scripts/run.ps1

The script shows each default and setting in this skill with FSharp.SystemTextJson 1.4.36 and Thoth.Json.System.Text.Json 0.5.0 on .NET 10. It fails when a result differs from the expected result.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.