Streamlined Discriminated Union Decoding in TypeScript with jsonous's New Decoder


TypeScript developers love discriminated unions (or tagged unions). They provide a fantastic way to model states, events, or different kinds of data structures in a type-safe manner. When working with external data sources like JSON APIs, however, decoding these unions reliably can sometimes feel a bit cumbersome.
Here at jsonous
, we aim to make JSON decoding as painless and type-safe as possible. While our existing oneOf
decoder could handle unions, it wasn't specifically optimized for the common discriminated union pattern. Today, we're excited to introduce a new tool designed precisely for this job: the discriminatedUnion
decoder!
The Challenge: Decoding Discriminated Unions "The Old Way"
Let's consider a common example: representing different types of users in our system.
// Our TypeScript types
interface User {
type: 'user';
id: string;
name: string;
isActive: boolean;
}
interface Admin {
type: 'admin';
id: string;
name: string;
permissions: string[];
}
type Person = User | Admin;
We have a Person
type which can be either a User
or an Admin
, distinguished by the type
field.
Using jsonous
previously, you'd typically define decoders for each variant and combine them with oneOf
:
import {
string,
boolean,
array,
stringLiteral,
createDecoderFromStructure,
oneOf,
Decoder,
identity // Needed for mapping
} from 'jsonous';
// Decoders for each variant
const userDecoder: Decoder<User> = createDecoderFromStructure({
type: stringLiteral('user'),
id: string,
name: string,
isActive: boolean,
});
const adminDecoder: Decoder<Admin> = createDecoderFromStructure({
type: stringLiteral('admin'),
id: string,
name: string,
permissions: array(string),
});
// --- The "Old Way" using oneOf ---
const personDecoderOneOf: Decoder<Person> = oneOf([
// We need to explicitly map each decoder to the union type
userDecoder.map<Person>(identity),
adminDecoder.map<Person>(identity),
]);
This works, but has a few drawbacks:
Verbosity: You need
.map<Person>(identity)
for every single variant. This adds boilerplate, especially with many variants.Less Specific Errors: If decoding fails,
oneOf
tries every decoder in the list and reports all failures. For a discriminated union, you often intuitively know which variant should have matched based on thetype
field, making the other errors noise.Potential Inefficiency:
oneOf
might run multiple potentially complex decoders even if thetype
field clearly indicates only one is relevant.
Introducing discriminatedUnion
: The Right Tool for the Job
The new discriminatedUnion
decoder is designed to address these pain points directly. It leverages the discriminator field (type
in our case) to intelligently select and run the correct decoder.
Here's how it works:
You tell it the name of the
discriminatorField
(e.g.,"type"
).You provide a
mapping
object where keys are the possible string values of the discriminator (e.g.,"user"
,"admin"
) and values are the corresponding decoders for each variant.It first decodes only the discriminator field.
Based on the value found, it looks up the correct decoder in your mapping.
It runs only that specific decoder on the original input.
Let's rewrite our Person
decoder using discriminatedUnion
:
import {
// ... other imports remain the same ...
discriminatedUnion // Import the new decoder
} from 'jsonous';
// userDecoder and adminDecoder definitions remain the same...
// --- The "New Way" using discriminatedUnion ---
const personDecoder: Decoder<Person> = discriminatedUnion('type', {
user: userDecoder, // Key matches the 'type' value
admin: adminDecoder, // Key matches the 'type' value
});
// Type check: personDecoder is automatically Decoder<Person> - no mapping needed!
Look how much cleaner that is! No more .map(identity)
. The decoder's type Decoder<Person>
is inferred automatically from the provided mapping.
Why discriminatedUnion
is Better
This new decoder offers significant advantages:
Type Safety: Automatically infers the correct union type (
User | Admin
in this case) without manual type hints in.map
.Conciseness: Eliminates the repetitive
.map(identity)
calls, making your decoder definitions cleaner and easier to read.Clarity: The structure
discriminatedUnion('field', { key1: decoder1, key2: decoder2 })
clearly expresses the intent of choosing a decoder based on a specific field's value.Targeted Errors: Error messages are much more helpful. Instead of trying all decoders, it fails fast with specific reasons:
Did the discriminator field (
type
) exist and was it a string?Was the value of the discriminator field (
"user"
,"admin"
) one of the expected keys in the mapping?Did the selected variant decoder (
userDecoder
oradminDecoder
) fail?
Efficiency: It avoids running unnecessary decoders. It only decodes the simple discriminator field first and then runs exactly one variant decoder.
A Clearer Picture: Error Handling
Let's see the difference in error messages. Consider this invalid input:
const invalidData = { type: 'guest', id: 'guest-001' };
Error with oneOf
:
// personDecoderOneOf.decodeAny(invalidData) might produce:
Err: I found the following problems:
Expected user but got "guest":
occurred in a field named 'type'
Expected admin but got "guest":
occurred in a field named 'type'
(It tells you both decoders failed because the type
was wrong).
Error with discriminatedUnion
:
// personDecoder.decodeAny(invalidData) produces:
Err: Unexpected discriminator value 'guest' for field 'type'. Expected one of: user, admin. Found in: {"type":"guest","id":"guest-001"}
(It tells you exactly the problem: the value "guest"
wasn't expected for the type
field).
If the type
was correct but other data was wrong (e.g., isActive
was a string for a user
), discriminatedUnion
would report the error from within the userDecoder
, prefixed clearly:
// Example: { type: 'user', id: 'u1', name: 'Test', isActive: 'yes' }
Err: Error decoding variant with type='user': I expected to find a boolean but instead I found "yes":
occurred in a field named 'isActive'
Get Started Today!
Decoding discriminated unions is now simpler, safer, and more efficient in jsonous
. If you're working with tagged unions and JSON, the discriminatedUnion
decoder is the tool you've been waiting for.
Update jsonous
to the latest version and give it a try! We think you'll appreciate the improved ergonomics and clearer error reporting. Check out the README for detailed usage and examples.
Happy Decoding!
Subscribe to my newsletter
Read articles from Ryan Bell directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
