· Tim van der Weijde · TypeScript · 3 min read
From .NET to TypeScript - Clean Business Logic with Discriminated Unions
Discriminated unions in TypeScript are a simple but powerful way to keep business logic predictable, safe, and maintainable. Especially useful for developers coming from a .NET background.

When working with business logic in typed environments, it helps to have patterns that keep things clean and reliable. While exploring more of TypeScript, I came across discriminated unions, which turned out to be a very practical fit. Simple in concept, powerful in practice. Here’s why they’re worth knowing.
What is a discriminated union?
A discriminated union is a union type where each variant has a distinct literal value for a shared property. This makes it easy to switch between them using that shared key, without extra type guards or casting.
Here’s a basic example:
type OrderEvent =
| { kind: 'placed'; orderId: string }
| { kind: 'shipped'; trackingCode: string }
| { kind: 'cancelled'; reason: string }
function handle(event: OrderEvent) {
if (event.kind === 'placed') {
console.log(`Order placed: ${event.orderId}`)
} else if (event.kind === 'shipped') {
console.log(`Shipped with: ${event.trackingCode}`)
} else if (event.kind === 'cancelled') {
console.log(`Cancelled because: ${event.reason}`)
}
}
The TypeScript compiler understands the structure of each event
based on its kind
value. That means you get proper autocomplete, reliable type narrowing, and you can skip as
casts and custom type checks entirely. It understands your intent without needing extra code
to clarify it.
This is especially useful in real-world business logic where the shape of your data changes depending on its status or context. Whether you’re handling success/error results from APIs, processing domain events, or updating a state machine, discriminated unions ensure that your code is always in sync with the data it’s handling. You’ll catch mistakes early, right in your editor, before they ever hit runtime.
Cleaner handling with maps
For more maintainable logic, especially when events grow, you can use a mapped object to separate handling logic:
type OrderEvent =
| { kind: 'placed'; orderId: string }
| { kind: 'shipped'; trackingCode: string }
| { kind: 'cancelled'; reason: string }
type Handlers = {
[K in OrderEvent['kind']]: (event: Extract<OrderEvent, { kind: K }>) => void
}
const handlers: Handlers = {
placed: (e) => console.log(`Order placed: ${e.orderId}`),
shipped: (e) => console.log(`Shipped with: ${e.trackingCode}`),
cancelled: (e) => console.log(`Cancelled because: ${e.reason}`)
}
function handle(event: OrderEvent) {
handlers[event.kind](event)
}
This makes your code easier to extend and reason about, especially in domains with multiple business states or event types.
Why it matters
Discriminated unions are great for modelling logic tied to states, types, or results. Think of:
- API response handling
- Domain event processing
- State transitions
They make invalid states impossible and remove most of the runtime guessing.
.NET comparison
If you come from a .NET background, this pattern might look familiar:
- Where you’d use
OneOf<T1, T2>
orResult<TSuccess, TError>
, in TypeScript you can shape that union directly with plain object types. - TypeScript narrows the type automatically based on a shared key (
kind
), so there’s no need foras
, pattern matching, or manual checks. - Instead of inheritance or interfaces with abstract methods, TypeScript uses structural typing, keeping things flexible while still strongly typed.
The result is less boilerplate
, better autocompletion
, and a predictable flow of logic
. It fits well if you’re already used to writing clean, strongly typed business code
.
Summary
- Use discriminated unions to model clear, type-safe business logic
- Let TypeScript narrow types automatically based on shared keys
- Reduce branching and boilerplate using handler maps
- A practical and readable way to deal with status-based logic
- Familiar for .NET developers, just simpler and more flexible