decoders

Idiomatic decoders

Common mistakes when writing decoders, and what to write instead.

Decoders are small building blocks that you snap together. Most mistakes come from forgetting that, and writing validation logic by hand where a composed decoder would do the job better. This guide lists the most common patterns to avoid, each with a side-by-side "don't" and "do".

In short:

❌ Don't✅ Do instead
Name decoders after fieldsName them after the data type
Bake optional() into a reusable decoderApply optional() where the field is defined
Hand-roll checks with typeofDescribe the shape with object()
Use either() for objects with a "kind" fieldUse taggedUnion()
Use either() if the "kind" can be missingUse select() to pick a decoder
Build decoders inside callbacksBuild them once, at the module level
Validate inside .transform()Compose decoders, then transform
Call .verify() inside .transform()Use .pipe()

Name decoders after data types, not fields

A decoder describes a kind of value: an email address, a user ID, a date. It does not describe the field it happens to be stored in. Name decoders after what the value is, and reuse them for every field that holds that kind of value.

❌ Don't
const createdByDecoder = regex(/^user_\w+$/, 'Invalid creator');
const assigneeDecoder = regex(/^user_\w+$/, 'Invalid assignee');

const task = object({
  createdBy: createdByDecoder,
  assignee: optional(assigneeDecoder),
});
✅ Do
const userId = regex(/^user_\w+$/, 'Must be a user ID');

const task = object({
  createdBy: userId,
  assignee: optional(userId),
});

There's no need for field-specific error messages either: decoders already report which field failed.

The field names belong in the object() definition. The decoder captures the rules for the data type, so they're written once and stay consistent everywhere. If the format of a user ID ever changes, there's exactly one place to update.


Keep optional() out of reusable decoders

Whether a value may be missing is a property of the field, not of the value. So don't bake optional(), nullable(), or nullish() into a named, reusable decoder. Apply them where the field is defined instead.

❌ Don't
export const userId = optional(regex(/^user_\w+$/, 'Must be a user ID'));

const task = object({
  createdBy: userId, // Oops, now this may be missing too!
  assignee: userId,
});
✅ Do
export const userId = regex(/^user_\w+$/, 'Must be a user ID');

const task = object({
  createdBy: userId,
  assignee: optional(userId),
});

In the first version, every user of userId silently accepts undefined, even in places where a user ID is required. The type string | undefined then leaks into all code that touches it. Keeping the edge case outside makes the decoder a smaller and more reusable building block, and wrapping it in optional() at the call site is cheap.


Describe the shape, don't check it by hand

If you catch yourself writing typeof x === "..." checks, or little helpers like hasOptionalString(obj, key), it’s often a smell.

❌ Don't
function hasOptionalString(obj, key) {
  return !(key in obj) || typeof obj[key] === 'string';
}

function isUser(x): x is User {
  return (
    typeof x === 'object' &&
    x !== null &&
    typeof x.name === 'string' &&
    hasOptionalString(x, 'nickname')
  );
}

const user = unknown.refine(isUser, 'Must be a user');
✅ Do
const user = object({
  name: string,
  nickname: optional(string),
});

The composed version is shorter, infers the User type for you (no separate type to keep in sync), and gives precise error messages. The hand-rolled version also has a subtle bug: { nickname: undefined } is rejected because the in check sees the key. Compare:

Try it
const handRolled = unknown.refine(isUser, 'Must be a user');const user = object({ name: string, nickname: optional(string) });handRolled.verify(input)user.verify(input)
Input❌ handRolled✅ user
{ "name": "Alice" }{ "name": "Alice" }
{ "name": "Alice", "nickname": undefined, } ^ Must be a user{ "name": "Alice" }
{ "name": "Alice", "nickname": 42, } ^ Must be a user{ "name": "Alice", "nickname": 42, ^^ Either: - Must be undefined - Must be string }
{ "nickname": "Al", } ^ Must be a user{ "nickname": "Al", } ^ Missing key: 'name'

Notice how the hand-rolled version can only ever say "Must be a user", while the composed version points at exactly what's wrong.


Use taggedUnion() for tagged objects

A common data shape is a set of objects that share a "tag" field, like kind or type, that tells you which shape the rest of the object has. You can decode those with either(), but you shouldn't.

❌ Don't
const circle = object({ kind: constant('circle'), radius: number });
const square = object({ kind: constant('square'), size: number });

const shape = either(circle, square);
✅ Do
const circle = object({ kind: constant('circle'), radius: number });
const square = object({ kind: constant('square'), size: number });

const shape = taggedUnion('kind', { circle, square });

either() tries each decoder in turn and, if they all fail, reports every failure. taggedUnion() looks at the kind field first, and then only runs the one decoder that matches. That's faster, and the error messages are much clearer:

Try it
const circle = object({ kind: constant('circle'), radius: number });const square = object({ kind: constant('square'), size: number });either(circle, square).verify(input)taggedUnion('kind', { circle, square }).verify(input)
Input❌ either✅ taggedUnion
{ "kind": "square", "size": 3 }{ "kind": "square", "size": 3 }
{ "kind": "square", "size": "big", } ^ Either: - Value at key 'kind': Must be 'circle' Missing key: 'radius' - Value at key 'size': Must be number{ "kind": "square", "size": "big", ^^^^^ Must be number }
{ "kind": "triangle", } ^ Either: - Value at key 'kind': Must be 'circle' Missing key: 'radius' - Value at key 'kind': Must be 'square' Missing key: 'size'{ "kind": "triangle", ^^^^^^^^^^ Must be one of 'circle', 'square' }

When the tag can be missing, use select()

Sometimes the tag field isn't always there. For example, older records were saved before the kind field was introduced, and all of those were circles. taggedUnion() can't handle a missing tag, so it's tempting to fall back to either() again. Instead, use select(): peek at the tag first, and then pick the decoder to use.

❌ Don't
const legacyCircle = object({ radius: number }).transform(({ radius }) => ({
  kind: 'circle' as const,
  radius,
}));

const shape = either(circle, square, legacyCircle);
✅ Do
const legacyCircle = object({ radius: number }).transform(({ radius }) => ({
  kind: 'circle' as const,
  radius,
}));
const byKind = taggedUnion('kind', { circle, square });

const shape = select(
  object({ kind: unknown }), // peek at the tag...
  ({ kind }) => (kind === undefined ? legacyCircle : byKind), // ...then pick
);

This works exactly like taggedUnion(), but you decide how to map the tag to a decoder. Every input still goes through exactly one decoder, so the error messages stay focused:

Try it
const byKind = taggedUnion('kind', { circle, square });const shape = select(  object({ kind: unknown }),  ({ kind }) => (kind === undefined ? legacyCircle : byKind),);shape.verify(input)
InputResult
{ "kind": "circle", "radius": 3 }
{ "kind": "circle", "radius": 3 }
{ "kind": "square", "size": 2 }
{ "kind": "triangle", ^^^^^^^^^^ Must be one of 'circle', 'square' }

Build decoders once

Decoders are values. Creating one does some upfront work, so build every decoder once, at the module level. Callbacks, like the one you pass to select(), run on every single decode. They should typically only pick an existing decoder, not construct a new one on every call.

❌ Don't
const shape = select(object({ kind: unknown }), ({ kind }) =>
  kind === undefined ? legacyCircle : taggedUnion('kind', { circle, square }),
);
✅ Do
// Construct it once, upfront
const byKind = taggedUnion('kind', { circle, square });

const shape = select(object({ kind: unknown }), ({ kind }) =>
  kind === undefined ? legacyCircle : byKind,
);

In the first version, a brand new taggedUnion() gets built for every value you decode.

The same applies inside your own factory functions: build the branches in the body of the factory, outside of any callback.

❌ Don't
function withLegacy(legacy, mapping) {
  return select(object({ kind: unknown }), ({ kind }) =>
    kind === undefined ? legacy : taggedUnion('kind', mapping),
  );
}
✅ Do
function withLegacy(legacy, mapping) {
  const byKind = taggedUnion('kind', mapping);
  return select(object({ kind: unknown }), ({ kind }) =>
    kind === undefined ? legacy : byKind,
  );
}

Don't validate inside .transform()

A .transform() is for changing a value that's already been validated. If your transform contains if statements, as casts, or returns "sentinel" values like null or false that get rejected later, it's doing validation by hand. And hand-rolled validation only checks what its author thought of, so it quietly accepts everything else.

Here's an example: a screen resolution, written as a string like "1920x1080", that we want to turn into { width: 1920, height: 1080 }.

❌ Don't
const resolution = string
  .transform((s) => {
    const parts = s.split('x');
    if (parts.length < 2) {
      return null;
    }
    return { width: Number(parts[0]), height: Number(parts[1]) };
  })
  .reject((v) => (v === null ? 'Invalid resolution' : null));
✅ Do
const resolution = string
  .transform((s) => s.split('x'))
  .pipe(tuple(numeric, numeric))
  .transform(([width, height]) => ({ width, height }));

In the composed version, the shape is the validation: "exactly two parts, both numeric". The types are inferred without any casts, and there's nothing to forget. The hand-rolled version only checks that there are at least two parts. It happily accepts "1920x" (because Number('') is 0), non-numbers (which become NaN), and extra junk at the end.

Use a factory when copies only differ by a parameter

If you have several near-identical decoders, turn them into a function:

function numberPair(separator: string) {
  return string.transform((s) => s.split(separator)).pipe(tuple(numeric, numeric));
}

const resolution = numberPair('x'); // "1920x1080"
const aspectRatio = numberPair(':'); // "16:9"

Use .pipe() to feed into another decoder

Calling .verify(), .decode(), or .value() on another decoder inside a .transform() is a code smell. To send a transformed value into another decoder, use .pipe().

❌ Don't
const tags = string
  .transform((s) => s.split(','))
  .transform((parts) => array(nonEmptyString).verify(parts));
✅ Do
const tags = string
  .transform((s) => s.split(','))
  .pipe(array(nonEmptyString));

.transform() catches anything thrown inside it, and turns the error message into a plain-text error on the outer value. So .verify() first formats the inner error into a string, and that string then gets stuffed into the outer error. You end up with an error message inside an error message:

Try it
string  .transform((s) => s.split(','))  .transform((parts) => array(nonEmptyString).verify(parts))  .verify(input)string  .transform((s) => s.split(','))  .pipe(array(nonEmptyString))  .verify(input)
Input❌ verify✅ pipe
["red", "green", "blue"]["red", "green", "blue"]
[ "red", "", "blue", ] ^ [ "red", "", ^^ Must be non-empty string (at index 1) "blue", ][ "red", "", ^^ Must be non-empty string (at index 1) "blue", ]

With .pipe(), the inner error is kept intact and composes like any other decoder error.

The same goes for .refine() and .reject() predicates, and for the callbacks of select() and .pipe(): return the decoder to use, and never run it yourself.

On this page