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

Ryan BellRyan Bell
4 min read

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:

  1. Verbosity: You need .map<Person>(identity) for every single variant. This adds boilerplate, especially with many variants.

  2. 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 the type field, making the other errors noise.

  3. Potential Inefficiency: oneOf might run multiple potentially complex decoders even if the type 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:

  1. You tell it the name of the discriminatorField (e.g., "type").

  2. 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.

  3. It first decodes only the discriminator field.

  4. Based on the value found, it looks up the correct decoder in your mapping.

  5. 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:

  1. Type Safety: Automatically infers the correct union type (User | Admin in this case) without manual type hints in .map.

  2. Conciseness: Eliminates the repetitive .map(identity) calls, making your decoder definitions cleaner and easier to read.

  3. Clarity: The structure discriminatedUnion('field', { key1: decoder1, key2: decoder2 }) clearly expresses the intent of choosing a decoder based on a specific field's value.

  4. 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 or adminDecoder) fail?

  5. 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!

1
Subscribe to my newsletter

Read articles from Ryan Bell directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ryan Bell
Ryan Bell