skip to content
Stylized letter A in teal with pink sparkle accents Khalid Abuhakmeh

C# 15 Union Types: Build Serializable Workflows

/ 16 min read

Updated:

Every few years I end up flirting with the idea of a state machine, and every time I remember how complicated it gets. With C# 15, .NET ships union types (what many of us call a C# discriminated union), and I wanted to see if they could shrink that problem down to one state value and one method.

In this post, we’ll build a serializable workflow on .NET 11 where Next always returns a member of a union, and we’ll save and rehydrate it as JSON.

Everything here ran on 11.0.100-rc.1, so expect details to shift before general availability. Updated: October 2026.

What Are C# 15 Unions?

A C# 15 union declares a type that holds exactly one value from a closed list of case types.

public union Result(Success, Failure);
public record Success(string Message);
public record Failure(int Code);
  • These are type unions, not enum-style tagged cases. The cases are ordinary types you declare yourself, and records work well for them.
  • Cases are not nested in the union. You write Result r = new Success("ok"); and the compiler adds the implicit conversion.
  • The compiler generates a struct that stores the active case in a single object? Value.
  • A switch expression that covers every case is exhaustive, so there’s no default arm. If you need a refresher on the syntax, see my C# pattern matching quick guide.
static string Describe(Result r) => r switch
{
Success(var message) => message,
Failure(var code) => $"Error {code}",
};

That exhaustiveness is the reason I wanted unions for workflows. Add a new case, and the compiler points at every place that forgot to handle it.

To follow along, install the .NET 11 SDK and target net11.0. C# 15 is the default language version there, but I pin it to be explicit.

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>15.0</LangVersion>
</PropertyGroup>

If you use Homebrew on macOS, its dotnet may be first on your PATH and quietly use an older SDK. Check with dotnet --version.

Four Unions, One Method

A workflow, in my definition, is state plus a single method. Four small unions describe everything that moves through it:

Union Question it answers Cases
OrderState Where is the workflow? Drafted, Submitted, InReview, Approved, Rejected, Fulfilled
StepInput What is the caller sending in? NoInput, ApprovalAnswer
InputRequest What does a waiting step need? ApprovalRequest, ManagerApprovalRequest
StepResult What should the caller do next? Advance, AwaitInput, Finish, Fail
// Where the workflow is. Each case carries only the data that state needs.
public union OrderState(Drafted, Submitted, InReview, Approved, Rejected, Fulfilled);
public record Drafted(string OrderId, decimal Amount);
public record Submitted(string OrderId, decimal Amount);
public record InReview(string OrderId, decimal Amount);
public record Approved(string OrderId, decimal Amount);
public record Rejected(string OrderId, string Reason);
public record Fulfilled(string OrderId);
// What the caller sends in.
public union StepInput(NoInput, ApprovalAnswer);
public record NoInput;
public record ApprovalAnswer(bool Approved, string? Comment);
// What a waiting step asks the caller for.
public union InputRequest(ApprovalRequest, ManagerApprovalRequest);
public record ApprovalRequest(string Question);
public record ManagerApprovalRequest(string Question, decimal Amount);
// What Next() tells the caller to do.
public union StepResult(Advance, AwaitInput, Finish, Fail);
public record Advance(OrderState State);
public record AwaitInput(InputRequest Request);
public record Finish(OrderState State);
public record Fail(string Reason);

StepResult is the contract. Every call to Next returns one of four things, and the caller must handle all four:

  • Advance: the workflow moved, so the caller can call Next again.
  • AwaitInput: it needs an answer, so the caller should collect one.
  • Finish: it reached a terminal state, so the caller should stop.
  • Fail: the input didn’t belong to this step, so the caller should handle the error.

A Naming Rule That Avoids Collisions

Because union cases aren’t nested in the union, every case name shares a namespace with your other types. Naming by tense keeps them apart:

  • States are past tense, because they describe what has already happened: Submitted, Approved, Rejected, Fulfilled.
  • Results are present-tense instructions to the caller: Advance, AwaitInput, Finish, Fail.
  • Inputs and requests are nouns: ApprovalAnswer, ManagerApprovalRequest.

A state is a condition and a result is a command, so the sets never overlap, and the caller reads like a script. Fail is a technical failure, like input sent to the wrong step. A business “no” such as Rejected arrives as a Finish carrying a Rejected state.

How Do You Type Workflow Input?

Make the input a union too. The tempting alternative is Next(object? input) with type checks inside each step. That compiles, but the compiler can’t tell you what a step expects, and null ends up meaning both “no input” and “a mistake”.

With StepInput, there’s no null at all. NoInput means “I’m just asking what’s next”, and every step handles each input case. A waiting step also tells the caller what it wants with a typed InputRequest, instead of a prompt string.

The Workflow

Here’s the whole workflow class. Next is one switch expression, so the compiler checks that every state, and every input for a state that waits, is handled.

public sealed class OrderWorkflow
{
public OrderState State { get; private set; }
private OrderWorkflow(OrderState state) => State = state;
public static OrderWorkflow Create(string orderId, decimal amount) =>
new(new Drafted(orderId, amount));
public static OrderWorkflow FromState(OrderState state) => new(state);
public StepResult Next(StepInput input) => State switch
{
Drafted(var id, var amount) =>
NoInputOnly(input, () => MoveTo(new Submitted(id, amount))),
Submitted(var id, var amount) => input switch
{
NoInput => new AwaitInput(new ApprovalRequest("Approve submission for review?")),
ApprovalAnswer(false, _) => EndWith(new Rejected(id, "Submission not approved for review.")),
ApprovalAnswer(true, _) when amount > 1000m => MoveTo(new InReview(id, amount)),
ApprovalAnswer(true, _) => MoveTo(new Approved(id, amount)),
},
InReview(var id, var amount) => input switch
{
NoInput => new AwaitInput(new ManagerApprovalRequest("Manager approval required.", amount)),
ApprovalAnswer(false, _) => EndWith(new Rejected(id, "Manager rejected the order.")),
ApprovalAnswer(true, _) => MoveTo(new Approved(id, amount)),
},
Approved(var id, _) => NoInputOnly(input, () => EndWith(new Fulfilled(id))),
Rejected => EndWith(State),
Fulfilled => EndWith(State),
};
private StepResult MoveTo(OrderState next)
{
State = next;
return new Advance(State);
}
private StepResult EndWith(OrderState terminal)
{
State = terminal;
return new Finish(State);
}
// Steps that don't wait for anything reject unexpected input.
private static StepResult NoInputOnly(StepInput input, Func<StepResult> step) => input switch
{
NoInput => step(),
ApprovalAnswer => new Fail("This step does not take input."),
};
}

Add a new StepInput case, and every input switch above lights up with a warning until you handle it. Add a new state, and the outer State switch warns the same way.

Driving the Workflow

The caller loops until it gets a Finish or a Fail. Notice that the caller answers a request by switching on its type, which is exhaustive too.

static StepResult Run(OrderWorkflow wf, bool[] answers)
{
var queue = new Queue<bool>(answers);
StepInput input = new NoInput();
while (true)
{
var result = wf.Next(input);
switch (result)
{
case Finish:
case Fail:
return result;
case AwaitInput(var request):
input = Answer(request, queue.Dequeue()); // in real life: a person or a webhook
break;
case Advance:
input = new NoInput();
break;
}
}
}
static StepInput Answer(InputRequest request, bool approve) => request switch
{
ApprovalRequest => new ApprovalAnswer(approve, null),
ManagerApprovalRequest => new ApprovalAnswer(approve, "Reviewed by manager"),
};

For a $250 order, the workflow walks Drafted → Submitted → Approved → Fulfilled. For a $9,999 order, it takes a detour through InReview, and a “no” from the manager lands in Rejected:

state=InReview(id=order-3, amount=9999) -> AwaitInput(ManagerApprovalRequest: Manager approval required. (9999))
state=Rejected(id=order-3, reason=Manager rejected the order.) -> Finish(Rejected(id=order-3, reason=Manager rejected the order.))

Sending an answer to a step that isn’t waiting gives you a Fail rather than an exception:

Fail(This step does not take input.)

If you’ve ever written a C# state machine with an enum and a giant switch, the difference is that each state brings only the data it needs. A Rejected state has a reason, and a Drafted state doesn’t.

How Do You Serialize and Rehydrate a Union Workflow?

A workflow that can wait for a human is only useful if you can stop the process and pick it up later. That means serializing the current state and rehydrating it.

Serialize and rehydrate by persisting the State with an explicit discriminator. By default, System.Text.Json in .NET 11 serializes a union by writing only the active case’s JSON, so new Submitted("order-1", 250m) becomes {"OrderId":"order-1","Amount":250} with no tag. Reading it back works when the cases map to different JSON token types, such as a string and an object. Two object-shaped cases are ambiguous, so Deserialize<OrderState> throws:

JsonException: JSON value type 'Object' is ambiguous for union type 'OrderState'
because multiple case types can use this value type. Specify a custom type
classifier to support deserialization.

For stored workflow state, I don’t want to depend on shape-guessing. (If you’ve handled this for class hierarchies, it’s the same idea as polymorphic serialization with System.Text.Json.) I want an explicit tag that survives refactoring, so the state gets a small converter that wraps each case as {"$case": "...", "value": {...}}.

public sealed class OrderStateConverter : JsonConverter<OrderState>
{
public override OrderState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var kind = doc.RootElement.GetProperty("$case").GetString();
var value = doc.RootElement.GetProperty("value");
return kind switch
{
nameof(Drafted) => (OrderState)value.Deserialize<Drafted>(options)!,
nameof(Submitted) => (OrderState)value.Deserialize<Submitted>(options)!,
// ...one arm per case
_ => throw new JsonException($"Unknown OrderState case '{kind}'"),
};
}
public override void Write(Utf8JsonWriter writer, OrderState value, JsonSerializerOptions options)
{
writer.WriteStartObject();
switch (value)
{
case Drafted d:
writer.WriteString("$case", nameof(Drafted));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, d, options);
break;
// ...one arm per case
}
writer.WriteEndObject();
}
}

The persisted JSON is small and readable:

{"$case":"InReview","value":{"OrderId":"order-2","Amount":9999}}

Rehydrating is one line each way, because the whole workflow is its State:

var json = JsonSerializer.Serialize(wf.State, options);
wf = OrderWorkflow.FromState(JsonSerializer.Deserialize<OrderState>(json, options)!);

You don’t need to persist a pending question. After rehydrating a workflow that was waiting, call Next(new NoInput()) and it asks again with the same InputRequest.

To prove it works, the complete program at the end of this post serializes after every Next call, builds a brand-new OrderWorkflow from the JSON, and keeps driving that copy. It then asserts that the outcome matches an uninterrupted run. That holds for the approved path, the manager-review path, and the rejected path.

Things to Watch For

  • Prefer switch expressions. Only switch expressions get the union exhaustiveness check. A switch statement over a union isn’t treated as exhaustive, so a method ending in one fails with CS0161 unless you add a trailing throw.
  • Unions are structs, so default is a trap. default(OrderState).Value is null. With nullable reference types on, a switch expression over a union warns CS8655 because null isn’t covered, and switching on a default union throws SwitchExpressionException at run time. Always initialize union fields.
  • Value-type cases are boxed. Everything lives in an object? Value.
  • ?. and nullable returns don’t compile on unions. You’ll see CS0023 for ?., and CS0508 if a JsonConverter<OrderState> override returns OrderState?. That’s another reason NoInput beats null.
  • Nested unions and overlapping JSON shapes aren’t supported by the built-in classifier, per the System.Text.Json docs.
  • It’s a release candidate. The “What’s new in C# 15” page still describes C# 15 as preview, while the RC1 release notes say unions are stabilized. Behavior may change before GA.

Where to Go From Here

A few things this post leaves open, if you want to take it further:

  • Closed hierarchies with [JsonDerivedType]. The docs describe explicit discriminators through [JsonDerivedType(typeof(X), "tag")], and .NET 11 can infer polymorphism from a closed hierarchy. That could replace the hand-written converter.
  • Native AOT and source generation. I haven’t checked the converter under either.
  • Versioning. Rename Submitted, and stored $case values stop matching. A real system needs a version field or a migration step.
  • Per-state inputs. StepInput is shared by every step, so a step can still receive an input meant for another and answers with Fail. Making that impossible needs per-state input types, which means generics or more than one method.

Conclusion

Four small unions and one Next method cover the whole workflow, and the compiler flags every state, input, and result you forget to handle. The persisted state is a tagged JSON object that rehydrates to the same outcome as an uninterrupted run.

Next, try replacing the hand-written converter with [JsonDerivedType] and see how much code you can delete. If it works for your cases, I’d like to hear about it.

Thanks to the C# language team for shipping this one.

Complete Program

Here’s the whole thing in one file. Run dotnet new console with the .NET 11 SDK, set <LangVersion>15.0</LangVersion> in the project file, replace Program.cs with the code below, and run it. It runs the workflow with and without serialization, then prints the JSON at every step.

using System.Text.Json;
using System.Text.Json.Serialization;
// ---------------------------------------------------------------------------
// Demo: run a workflow with and without serialize/rehydrate at every step.
// ---------------------------------------------------------------------------
Console.WriteLine("=== Run A: uninterrupted ===");
var outcomeA = Run(OrderWorkflow.Create("order-1", 250m), [true, true], rehydrate: false);
Console.WriteLine();
Console.WriteLine("=== Run B: serialize/rehydrate at every step ===");
var outcomeB = Run(OrderWorkflow.Create("order-1", 250m), [true, true], rehydrate: true);
Console.WriteLine();
Console.WriteLine($"Run A outcome: {Describe(outcomeA)}");
Console.WriteLine($"Run B outcome: {Describe(outcomeB)}");
if (Describe(outcomeA) != Describe(outcomeB))
{
throw new Exception("Mismatch between uninterrupted and rehydrated runs!");
}
Console.WriteLine();
Console.WriteLine("=== Run C: large order needs manager review (approved) ===");
var outcomeC = Run(OrderWorkflow.Create("order-2", 9999m), [true, true], rehydrate: true);
Console.WriteLine($"Run C outcome: {Describe(outcomeC)}");
Console.WriteLine();
Console.WriteLine("=== Run D: manager rejects the order ===");
var outcomeD = Run(OrderWorkflow.Create("order-3", 9999m), [true, false], rehydrate: true);
Console.WriteLine($"Run D outcome: {Describe(outcomeD)}");
if (!Describe(outcomeD).StartsWith("Finish(Rejected("))
{
throw new Exception("Expected a rejected outcome.");
}
Console.WriteLine();
Console.WriteLine("=== Run E: input sent to a step that doesn't take input ===");
var outcomeE = OrderWorkflow.Create("order-4", 50m).Next(new ApprovalAnswer(true, null));
Console.WriteLine($"Run E outcome: {Describe(outcomeE)}");
if (outcomeE is not Fail)
{
throw new Exception("Expected Fail.");
}
Console.WriteLine();
Console.WriteLine("ALL ASSERTIONS PASSED.");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// The caller drives the workflow. Every AwaitInput carries a typed request, so
// the caller knows what kind of answer to build.
static StepResult Run(OrderWorkflow wf, bool[] answers, bool rehydrate)
{
var queue = new Queue<bool>(answers);
StepInput input = new NoInput();
while (true)
{
var result = wf.Next(input);
Console.WriteLine($" state={DescribeState(wf.State)} -> {Describe(result)}");
if (rehydrate)
{
// Serialize the workflow after every Next() call, then keep driving
// a brand-new instance built from the JSON.
var json = WorkflowJson.Serialize(wf);
Console.WriteLine($" json: {json}");
var rehydrated = WorkflowJson.Deserialize(json);
if (DescribeState(wf.State) != DescribeState(rehydrated.State))
{
throw new Exception("Round-trip mismatch!");
}
wf = rehydrated;
}
switch (result)
{
case Finish:
case Fail:
return result;
case AwaitInput(var request):
input = Answer(request, queue.Dequeue());
break;
case Advance:
input = new NoInput();
break;
}
}
}
// The request type decides which answer is valid; the switch is exhaustive.
static StepInput Answer(InputRequest request, bool approve) => request switch
{
ApprovalRequest => new ApprovalAnswer(approve, null),
ManagerApprovalRequest => new ApprovalAnswer(approve, "Reviewed by manager"),
};
static string Describe(StepResult r) => r switch
{
Advance(var state) => $"Advance({DescribeState(state)})",
AwaitInput(var request) => $"AwaitInput({DescribeRequest(request)})",
Finish(var state) => $"Finish({DescribeState(state)})",
Fail(var reason) => $"Fail({reason})",
};
static string DescribeRequest(InputRequest r) => r switch
{
ApprovalRequest(var question) => $"ApprovalRequest: {question}",
ManagerApprovalRequest(var question, var amount) => $"ManagerApprovalRequest: {question} ({amount})",
};
static string DescribeState(OrderState s) => s switch
{
Drafted(var id, var amount) => $"Drafted(id={id}, amount={amount})",
Submitted(var id, var amount) => $"Submitted(id={id}, amount={amount})",
InReview(var id, var amount) => $"InReview(id={id}, amount={amount})",
Approved(var id, var amount) => $"Approved(id={id}, amount={amount})",
Rejected(var id, var reason) => $"Rejected(id={id}, reason={reason})",
Fulfilled(var id) => $"Fulfilled(id={id})",
};
// =============================================================================
// Union declarations (C# 15 `union` feature)
//
// Naming: states are past tense (what has happened). Results are present-tense
// instructions to the caller. Inputs and requests are nouns.
// =============================================================================
// States. Each case carries only the data that state needs.
public union OrderState(Drafted, Submitted, InReview, Approved, Rejected, Fulfilled);
public record Drafted(string OrderId, decimal Amount);
public record Submitted(string OrderId, decimal Amount);
public record InReview(string OrderId, decimal Amount);
public record Approved(string OrderId, decimal Amount);
public record Rejected(string OrderId, string Reason);
public record Fulfilled(string OrderId);
// What the caller sends in.
public union StepInput(NoInput, ApprovalAnswer);
public record NoInput;
public record ApprovalAnswer(bool Approved, string? Comment);
// What a waiting step asks the caller for.
public union InputRequest(ApprovalRequest, ManagerApprovalRequest);
public record ApprovalRequest(string Question);
public record ManagerApprovalRequest(string Question, decimal Amount);
// What Next() tells the caller to do.
public union StepResult(Advance, AwaitInput, Finish, Fail);
public record Advance(OrderState State);
public record AwaitInput(InputRequest Request);
public record Finish(OrderState State);
public record Fail(string Reason);
// =============================================================================
// Workflow
// =============================================================================
public sealed class OrderWorkflow
{
public OrderState State { get; private set; }
private OrderWorkflow(OrderState state) => State = state;
public static OrderWorkflow Create(string orderId, decimal amount) =>
new(new Drafted(orderId, amount));
public static OrderWorkflow FromState(OrderState state) => new(state);
// The ONE method. Always returns a StepResult, and takes a typed StepInput.
public StepResult Next(StepInput input) => State switch
{
Drafted(var id, var amount) => NoInputOnly(input, () => MoveTo(new Submitted(id, amount))),
Submitted(var id, var amount) => input switch
{
NoInput => new AwaitInput(new ApprovalRequest("Approve submission for review?")),
ApprovalAnswer(false, _) => EndWith(new Rejected(id, "Submission not approved for review.")),
ApprovalAnswer(true, _) when amount > 1000m => MoveTo(new InReview(id, amount)),
ApprovalAnswer(true, _) => MoveTo(new Approved(id, amount)),
},
InReview(var id, var amount) => input switch
{
NoInput => new AwaitInput(new ManagerApprovalRequest("Manager approval required.", amount)),
ApprovalAnswer(false, _) => EndWith(new Rejected(id, "Manager rejected the order.")),
ApprovalAnswer(true, _) => MoveTo(new Approved(id, amount)),
},
Approved(var id, _) => NoInputOnly(input, () => EndWith(new Fulfilled(id))),
Rejected => EndWith(State),
Fulfilled => EndWith(State),
};
private StepResult MoveTo(OrderState next)
{
State = next;
return new Advance(State);
}
private StepResult EndWith(OrderState terminal)
{
State = terminal;
return new Finish(State);
}
// Steps that don't wait for anything reject unexpected input.
private static StepResult NoInputOnly(StepInput input, Func<StepResult> step) => input switch
{
NoInput => step(),
ApprovalAnswer => new Fail("This step does not take input."),
};
}
// =============================================================================
// JSON serialization support
// =============================================================================
//
// What was tried:
//
// 1. Default System.Text.Json behavior for a `union`. Serializing writes only the
// active case's own JSON (no discriminator), and deserializing throws a
// JsonException ("ambiguous for union type ... specify a custom type classifier")
// when more than one case could match the JSON shape.
//
// 2. An explicit discriminator written by hand: every state is serialized as
// { "$case": "<name>", "value": { ... } } via a custom JsonConverter<OrderState>.
// This is what's used below.
public sealed class OrderStateConverter : JsonConverter<OrderState>
{
public override OrderState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
var kind = root.GetProperty("$case").GetString();
var value = root.GetProperty("value");
return kind switch
{
nameof(Drafted) => (OrderState)value.Deserialize<Drafted>(options)!,
nameof(Submitted) => (OrderState)value.Deserialize<Submitted>(options)!,
nameof(InReview) => (OrderState)value.Deserialize<InReview>(options)!,
nameof(Approved) => (OrderState)value.Deserialize<Approved>(options)!,
nameof(Rejected) => (OrderState)value.Deserialize<Rejected>(options)!,
nameof(Fulfilled) => (OrderState)value.Deserialize<Fulfilled>(options)!,
_ => throw new JsonException($"Unknown OrderState case '{kind}'"),
};
}
public override void Write(Utf8JsonWriter writer, OrderState value, JsonSerializerOptions options)
{
writer.WriteStartObject();
switch (value)
{
case Drafted d:
writer.WriteString("$case", nameof(Drafted));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, d, options);
break;
case Submitted s:
writer.WriteString("$case", nameof(Submitted));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, s, options);
break;
case InReview m:
writer.WriteString("$case", nameof(InReview));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, m, options);
break;
case Approved a:
writer.WriteString("$case", nameof(Approved));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, a, options);
break;
case Rejected r:
writer.WriteString("$case", nameof(Rejected));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, r, options);
break;
case Fulfilled c:
writer.WriteString("$case", nameof(Fulfilled));
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, c, options);
break;
default:
throw new JsonException($"Unknown OrderState case '{value.GetType()}'");
}
writer.WriteEndObject();
}
}
public static class WorkflowJson
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = false,
Converters = { new OrderStateConverter() },
};
public static string Serialize(OrderWorkflow wf) =>
JsonSerializer.Serialize(wf.State, Options);
public static OrderWorkflow Deserialize(string json)
{
var state = JsonSerializer.Deserialize<OrderState>(json, Options)!;
return OrderWorkflow.FromState(state);
}
}

Sources

Related Posts