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 fields | Name them after the data type |
Bake optional() into a reusable decoder | Apply optional() where the field is defined |
Hand-roll checks with typeof | Describe the shape with object() |
Use either() for objects with a "kind" field | Use taggedUnion() |
Use either() if the "kind" can be missing | Use select() to pick a decoder |
| Build decoders inside callbacks | Build 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.
const createdByDecoder = regex(/^user_\w+$/, 'Invalid creator');
const assigneeDecoder = regex(/^user_\w+$/, 'Invalid assignee');
const task = object({
createdBy: createdByDecoder,
assignee: optional(assigneeDecoder),
});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.
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,
});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.
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');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:
| 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.
const circle = object({ kind: constant('circle'), radius: number });
const square = object({ kind: constant('square'), size: number });
const shape = either(circle, square);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:
| 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.
const legacyCircle = object({ radius: number }).transform(({ radius }) => ({
kind: 'circle' as const,
radius,
}));
const shape = either(circle, square, legacyCircle);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:
| Input | Result |
|---|---|
| { "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.
const shape = select(object({ kind: unknown }), ({ kind }) =>
kind === undefined ? legacyCircle : taggedUnion('kind', { circle, square }),
);// 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.
function withLegacy(legacy, mapping) {
return select(object({ kind: unknown }), ({ kind }) =>
kind === undefined ? legacy : taggedUnion('kind', mapping),
);
}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 }.
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));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().
const tags = string
.transform((s) => s.split(','))
.transform((parts) => array(nonEmptyString).verify(parts));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:
| 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.