Waystone.Monads.SystemTextJson 7.5.0

dotnet add package Waystone.Monads.SystemTextJson --version 7.5.0
                    
NuGet\Install-Package Waystone.Monads.SystemTextJson -Version 7.5.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="Waystone.Monads.SystemTextJson" Version="7.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Waystone.Monads.SystemTextJson" Version="7.5.0" />
                    
Directory.Packages.props
<PackageReference Include="Waystone.Monads.SystemTextJson" />
                    
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 Waystone.Monads.SystemTextJson --version 7.5.0
                    
#r "nuget: Waystone.Monads.SystemTextJson, 7.5.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 Waystone.Monads.SystemTextJson@7.5.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=Waystone.Monads.SystemTextJson&version=7.5.0
                    
Install as a Cake Addin
#tool nuget:?package=Waystone.Monads.SystemTextJson&version=7.5.0
                    
Install as a Cake Tool

Waystone.Monads.SystemTextJson

An interop package for using System.Text.Json with Waystone.Monads

Namespaces

The package shadows System.Text.Json's own namespaces, so its types sit where a consumer already looks for them rather than under a parallel Waystone tree:

Member Namespace
AddMonadConverters System.Text.Json
OptionJsonConverter<T>, OptionJsonConverterFactory System.Text.Json.Serialization
ResultJsonConverter<TOk, TErr>, ResultJsonConverterFactory System.Text.Json.Serialization

The converters follow JsonConverter<T> down into System.Text.Json.Serialization; the extension method sits beside the JsonSerializerOptions it extends.

Supported System.Text.Json versions

System.Text.Json >= 8.0.5 && < 11.0.0. Bring your own version inside that range; the package does not pin you to one. Every version in the range ships a netstandard2.0 and a net462 asset, so .NET Framework consumers are covered.

Registration

JsonSerializerOptions options = new();
options.AddMonadConverters();

string json = JsonSerializer.Serialize(model, options);

Call it once, while the options are still being built. System.Text.Json freezes a JsonSerializerOptions the first time it serializes with it, and adding a converter afterwards throws.

Option

Option<T> serializes as the value itself, or as null. This is the format Rust's serde uses, and it means a payload is the payload you would have written had the property been a plain T — adopting Option<T> in a model does not change the contract on the wire.

public sealed record Person
{
    public Option<string> Nickname { get; init; } = Option.None<string>();
}
{ "nickname": "Ally" }   // Option.Some("Ally")
{ "nickname": null }     // Option.None<string>()

None writes the property, it does not remove it

A converter cannot delete its own property from the enclosing object, so a None writes "nickname": null rather than omitting nickname.

JsonIgnoreCondition.WhenWritingNull does not help here. It tests the CLR value for null, and Option.None<T>() is an object like any other — never null — so the property is written regardless. The same goes for [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)].

What does work is a type-info modifier, which decides per property whether to write it at all:

options.TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
    Modifiers = { SkipNoneProperties },
};

static void SkipNoneProperties(JsonTypeInfo typeInfo)
{
    foreach (JsonPropertyInfo property in typeInfo.Properties)
    {
        property.ShouldSerialize = static (_, value) =>
            value is null
         || value.GetType() is not { IsGenericType: true } type
         || type.GetGenericTypeDefinition() != typeof(None<>);
    }
}

This package does not ship that modifier. Omitting a property is a decision about your wire contract, not about Option<T>, and a consumer who wants it usually wants it for some models and not others.

An absent property does not read back as None

If the property is missing from the payload entirely, System.Text.Json never invokes the converter for that member and the CLR member keeps its default — which for Option<T> is null, not None<T>(). Initialise the member, as Person.Nickname does above, or the model will hold a null where it promised an option.

Nested options collapse

Option<Option<T>> does not survive a round trip. Some(None) and None both write null, and both read back as None:

Option<Option<int>> before = Option.Some(Option.None<int>());
string json = JsonSerializer.Serialize(before, options);   // "null"
Option<Option<int>> after = JsonSerializer.Deserialize<Option<Option<int>>>(json, options)!;
// after is None<Option<int>>(), not Some(None<int>())

The converter accepts this rather than throwing, because throwing on a shape the type system allows is worse than losing a distinction nobody should be relying on. The WM2009 analyzer in Waystone.Monads.Analyzers already reports the declaration, which is the right place to catch it.

Result

Result<TOk, TErr> serializes as an object naming its case, with the payload nested under value:

{ "$type": "ok",  "value": 42 }
{ "$type": "err", "value": { "Code": "validation.failed", "Message": "..." } }

Unlike an option, a result has no idiomatic JSON shape to borrow. Both cases carry ordinary values of different types, so the case has to be named on the wire.

Why the payload is nested

$type is also System.Text.Json's own polymorphism discriminator. If your TOk or TErr is a polymorphic base carrying [JsonDerivedType], it writes a $type of its own. Nesting puts that one inside value, a level below the result's:

{ "$type": "ok", "value": { "$type": "cat", "Name": "Tom" } }

Flattening the payload alongside the discriminator would have made the two siblings, and the collision would surface only for consumers whose payload happens to be polymorphic. Nesting rules it out by construction.

The wire contract ignores your naming policy

$type, value, ok and err are fixed. JsonSerializerOptions.PropertyNamingPolicy does not rename them, so a camel-casing service and a snake-casing one still exchange the same payload — and so does Waystone.Monads.NewtonsoftJson, which writes the identical format.

Reading rejects what a result cannot hold

Deserializing throws JsonException when the payload is not an object, when $type is missing or is not a string, when value is missing, when $type names neither case, or when value reads as null. A result has no null case, so accepting one would push the failure somewhere later and harder to trace.

A null value is not rejected on sight. It is read as the case's own type first, so a payload whose own converter reads null still round-trips — most usefully Result<Option<T>, TErr>, where Ok(None) writes "value": null and reads back as Ok(None). Waystone.Monads.NewtonsoftJson reads it the same way.

Property order does not matter — {"value":42,"$type":"ok"} reads the same as the canonical order.

Trimming and NativeAOT

Both factories close their converter reflectively, once per monad type, and the serializer caches the result. Under NativeAOT that throws when a type argument is a value type:

NotSupportedException: 'OptionJsonConverter`1[System.Int32]' is missing native
code or metadata.

A generic instantiation over a value type needs its own compiled code, and the compiler cannot see through MakeGenericType to know it will be asked for one. Reference types all share a single compiled converter, so they are unaffected.

This is measured under PublishAot on .NET 10, not inferred:

Registered through Type argument Under NativeAOT
AddMonadConverters() reference type works
AddMonadConverters() value type throws
options.Converters.Add(new …) either works

For Result<TOk, TErr> it is enough for one of the two arguments to be a value type.

Register value-type monads explicitly instead. The concrete converters are public with public parameterless constructors precisely so this path exists, and it involves no reflection at all:

options.Converters.Add(new OptionJsonConverter<int>());
options.Converters.Add(new ResultJsonConverter<int, string>());

Reference types are unaffected — every one of them shares a single compiled converter — so a model made of Option<string> and Result<Uri, Error> needs nothing extra.

Option<T> and Result<TOk, TErr> members do not get the source-generation fast path from a JsonSerializerContext. A factory-produced converter works from one, but only correctness is guaranteed, not the performance benefit.

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 is compatible.  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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
7.5.0 0 9/30/2026
7.4.0 87 9/15/2026
7.3.0 90 9/13/2026
7.2.0 98 9/9/2026
7.1.0 99 9/4/2026
7.1.0-beta.2 58 9/3/2026
7.1.0-beta.1 71 9/2/2026
7.0.1 97 8/30/2026
7.0.0 88 8/30/2026