This guide covers advanced patterns and features of ts.data.json. For basic usage, see the Basic Usage guide.
You can easily replicate the string decoder:
import * as JsonDecoder from 'ts.data.json';
import { ok, err } from 'ts.data.json';
const myStringDecoder: JsonDecoder.Decoder<string> = new JsonDecoder.Decoder((json: unknown) => {
if (typeof json === 'string') {
return ok(json);
} else {
return err([{ message: 'Expected a string', path: [] }]);
}
});
console.log(myStringDecoder.decode('Hello!')); // Ok({ value: 'Hello!' })
console.log(myStringDecoder.decode(123)); // Err({ issues: [{ message: 'Expected a string', path: [] }] })
Leverage built-in decoders and layer other decoders on top by following this pattern with the flatMap function.
const emailDecoder = JsonDecoder.string().flatMap(email => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email) ? JsonDecoder.succeed() : JsonDecoder.fail(`Invalid email format: ${email}`);
});
emailDecoder.decode('user@example.com'); // Ok({ value: 'user@example.com' })
emailDecoder.decode('not-an-email'); // Err({ issues: [{ message: 'Invalid email format: not-an-email', path: [] }] })
const dateDecoder = JsonDecoder.string().flatMap(str => {
const date = new Date(str);
return isNaN(date.getTime()) ? JsonDecoder.fail(`Invalid date format: ${str}`) : JsonDecoder.succeed();
});
dateDecoder.decode('2024-01-15'); // Ok({ value: '2024-01-15' })
dateDecoder.decode('not-a-date'); // Err({ issues: [{ message: 'Invalid date format: not-a-date', path: [] }] })
const ageDecoder = JsonDecoder.number().flatMap(age => {
return age >= 0 && age <= 120 ? JsonDecoder.succeed() : JsonDecoder.fail(`Age must be between 0 and 120, got: ${age}`);
});
ageDecoder.decode(25); // Ok({ value: 25 })
ageDecoder.decode(200); // Err({ issues: [{ message: 'Age must be between 0 and 120, got: 200', path: [] }] })
Handle recursive data structures like trees or linked lists:
interface TreeNode {
value: string;
children?: TreeNode[];
}
const treeDecoder: JsonDecoder.Decoder<TreeNode> = JsonDecoder.lazy(() =>
JsonDecoder.object<TreeNode>({
value: JsonDecoder.string(),
children: JsonDecoder.optional(JsonDecoder.array(treeDecoder))
})
);
const tree = {
value: 'root',
children: [
{ value: 'child1' },
{
value: 'child2',
children: [{ value: 'grandchild' }]
}
]
};
treeDecoder.decode(tree).map(node => console.log(JSON.stringify(node, null, 2))); // Ok(...)
const badTree = { ...tree, children: [...tree.children, { value: 12 }] };
treeDecoder.decode(badTree);
// Err({ issues: [{ message: '12 is not a valid string', path: ['children', 2, 'value'] }] })
For composite types, pick the combinator that matches the shape:
| You want… | Use |
|---|---|
| One of several alternatives, tried in order | oneOf([a, b, …]) |
| A tagged union of objects (shared literal field) | discriminatedUnion('kind', { … }) |
To merge several object decoders (intersection / A & B) |
allOf([a, b, …]) |
A value that may be null |
nullable(decoder) |
A value that may be undefined |
optional(decoder) |
oneOf is the general union: it returns the first match and, on failure, reports every
alternative. Prefer nullable/optional over oneOf([x, null])/oneOf([x, undefined]).
discriminatedUnionWhen your variants are objects sharing a literal "tag" field, reach for
discriminatedUnion. Knowing the tag field, it validates only the matching variant and
produces precise, single-variant errors — rather than oneOf, which would try every
branch and report all of their failures.
type Shape = { type: 'circle'; radius: number } | { type: 'rectangle'; width: number; height: number };
const shapeDecoder = JsonDecoder.discriminatedUnion('type', {
circle: JsonDecoder.object<Extract<Shape, { type: 'circle' }>>({
type: JsonDecoder.literal('circle'),
radius: JsonDecoder.number()
}),
rectangle: JsonDecoder.object<Extract<Shape, { type: 'rectangle' }>>({
type: JsonDecoder.literal('rectangle'),
width: JsonDecoder.number(),
height: JsonDecoder.number()
})
});
// Usage
const shapes = [
{ type: 'circle', radius: 5 },
{ type: 'rectangle', width: 10, height: 20 }
];
console.log(
JsonDecoder.array(shapeDecoder)
.decode(shapes)
.map(decodedShapes =>
decodedShapes.map(shape => {
if (shape.type === 'circle') {
return `Circle area: ${Math.PI * shape.radius ** 2}`;
} else {
return `Rectangle area: ${shape.width * shape.height}`;
}
})
)
); // Ok({ value: ["Circle area: 78.53981633974483", "Rectangle area: 200"] })
// A wrong field reports only the matching variant's failure:
shapeDecoder.decode({ type: 'circle', radius: 'big' });
// Err -> radius: "big" is not a valid number
// An unknown tag lists the expected values:
shapeDecoder.decode({ type: 'triangle' });
// Err -> type: "type" must be one of "circle", "rectangle", but got "triangle"
Transform decoded data into different structures:
type SnakeToCamel<S extends string> = S extends `${infer T}_${infer U}${infer Rest}` ? `${T}${Uppercase<U>}${SnakeToCamel<Rest>}` : S;
type CamelizedRecord<T extends Record<string, unknown>> = {
[K in keyof T as SnakeToCamel<K & string>]: T[K];
};
function camelizeRecord<T extends Record<string, unknown>>(decoder: JsonDecoder.Decoder<T>): JsonDecoder.Decoder<CamelizedRecord<T>> {
function snakeToCamel(str: string): string {
return str
.toLowerCase() // Ensure lowercase input
.replace(/[_]+([a-z])/g, (_, letter) => letter.toUpperCase()) // Convert _x to X
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
}
return decoder.flatMap(record => {
const camelizedRecord = Object.keys(record).reduce((acc, key) => {
const k = snakeToCamel(key);
(acc as Record<string, unknown>)[k] = record[key];
return acc;
}, {} as CamelizedRecord<T>);
return JsonDecoder.constant(camelizedRecord);
});
}
const camelizeApiUserDecoder = camelizeRecord(
JsonDecoder.object({
id: JsonDecoder.number(),
first_name: JsonDecoder.string(),
last_name: JsonDecoder.string(),
email_address: JsonDecoder.string()
})
);
type User = JsonDecoder.FromDecoder<typeof camelizeApiUserDecoder>;
const apiUserJson = {
id: 1,
first_name: 'John', // Notice these are snake cased!
last_name: 'Doe',
email_address: 'john@doe.com'
};
const user: User = await camelizeApiUserDecoder.decodePromise(apiUserJson);
// { id: 1, firstName: 'John', lastName: 'Doe', emailAddress: 'john@doe.com' }
Ensure no extra properties exist in objects:
interface MiniUser {
id: number;
name: string;
}
const strictUserDecoder = JsonDecoder.objectStrict<MiniUser>({
id: JsonDecoder.number(),
name: JsonDecoder.string()
});
// This will fail because of extra properties
strictUserDecoder.decode({
id: 1,
name: 'John',
extra: 'field'
});
// Err({ issues: [{ message: 'Unknown key "extra" found in strict object', path: [] }] })
Handle objects with dynamic keys:
interface MiniUser {
id: number;
name: string;
}
const miniUserDecoder = JsonDecoder.object<MiniUser>({
id: JsonDecoder.number(),
name: JsonDecoder.string()
});
// Map of user IDs to users
const userMapDecoder = JsonDecoder.record(miniUserDecoder);
const users = {
user1: { id: 1, name: 'John' },
user2: { id: 2, name: 'Jane' }
};
userMapDecoder.decode(users).map(userMap => {
console.log(userMap['user1']); // { id: 1, name: "John" }
});
Modular Decoders: Break down complex decoders into smaller, reusable parts:
const baseUserDecoder = JsonDecoder.object({...});
const adminUserDecoder = baseUserDecoder.flatMap(user => ...);
const regularUserDecoder = baseUserDecoder.flatMap(user => ...);
Validation Factories: Create functions that generate common validation patterns:
const createRangeDecoder = (min: number, max: number, name: string) => JsonDecoder.number().flatMap(n => (n >= min && n <= max ? JsonDecoder.succeed() : JsonDecoder.fail(`${name} must be between ${min} and ${max}`)));
const ageDecoder = createRangeDecoder(0, 120, 'Age');
const percentageDecoder = createRangeDecoder(0, 100, 'Percentage');
Error Context: Add meaningful context to error messages:
const dateDecoder = JsonDecoder.string().flatMap(str => {
const date = new Date(str);
return isNaN(date.getTime()) ? JsonDecoder.fail(`Invalid date format: ${str}`) : JsonDecoder.succeed();
});