Advanced Usage

This guide covers advanced patterns and features of ts.data.json. For basic usage, see the Basic Usage guide.

You can play with this examples in this stackblitz playground.

You can easily replicate the string decoder:

import * as JsonDecoder from 'ts.data.json';

const myStringDecoder: JsonDecoder.Decoder<string> = new JsonDecoder.Decoder((json: unknown) => {
if (typeof json === 'string') {
return ok(json);
} else {
return err('Expected a string');
}
});

console.log(myStringDecoder.decode('Hello!')); // Ok('Hello!)
console.log(myStringDecoder.decode(123)); // Err('Expected a string')

Leverage built-in decoders and layer other decoders on top by following this pattern with the chain function.

const emailDecoder = JsonDecoder.string().flatMap(email => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email) ? JsonDecoder.succeed() : JsonDecoder.fail(`Invalid email format: ${email}`);
});
const dateDecoder = JsonDecoder.string().flatMap(str => {
const date = new Date(str);
return isNaN(date.getTime()) ? JsonDecoder.fail(`Invalid date format: ${str}`) : JsonDecoder.succeed();
});
const ageDecoder = JsonDecoder.number().flatMap(age => {
return age >= 0 && age <= 120 ? JsonDecoder.succeed() : JsonDecoder.fail(`Age must be between 0 and 120, got: ${age}`);
});

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>(
{
label: JsonDecoder.string(),
children: JsonDecoder.optional(JsonDecoder.array(treeDecoder, 'TreeNode[]'))
},
'TreeNode'
)
);

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, { label: 12 }] };
treeDecoder.decode(badTree);
// Error: <TreeNode> decoder failed at key \"children\" with error: <TreeNode[]> decoder failed at index \"2\" with error: <TreeNode> decoder failed at key \"label\" with error: 12 is not a valid string"

Handle different object shapes based on a discriminator field:

type Shape = { type: 'circle'; radius: number } | { type: 'rectangle'; width: number; height: number };

const circleDecoder = JsonDecoder.object<Shape>(
{
type: JsonDecoder.constant('circle'),
radius: JsonDecoder.number()
},
'Circle'
);

const rectangleDecoder = JsonDecoder.object<Shape>(
{
type: JsonDecoder.constant('rectangle'),
width: JsonDecoder.number(),
height: JsonDecoder.number()
},
'Rectangle'
);

const shapeDecoder = JsonDecoder.oneOf<Shape>([circleDecoder, rectangleDecoder], 'Shape');

// Usage
const shapes = [
{ type: 'circle', radius: 5 },
{ type: 'rectangle', width: 10, height: 20 }
];

console.log(
JsonDecoder.array(shapeDecoder, 'Shape[]')
.decode(shapes)
.map(shapes =>
shapes.map(shape => {
if (shape.type === 'circle') {
return `Circle area: ${Math.PI * shape.radius ** 2}`;
} else {
return `Rectangle area: ${shape.width * shape.height}`;
}
})
)
); // {"value":["Circle area: 78.53981633974483","Rectangle area: 200"]}

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 → 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()
},
'User'
)
);

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()
},
'MiniUser'
);

// This will fail because of extra properties
strictUserDecoder.decode({
id: 1,
name: 'John',
extra: 'field'
}); // Error: Unknown key \"extra\" found while processing strict <MiniUser> decoder

Handle objects with dynamic keys:

interface MiniUser {
id: number;
name: string;
}

// Map of user IDs to users
interface UserMap {
[key: string]: MiniUser;
}

const userMapDecoder = JsonDecoder.record(userDecoder, 'UserMap');

const users: UserMap = {
"user1": { id: 1, name: "John" },
"user2": { id: 2, name: "Jane" }
};

userMapDecoder.decode(users: UserMap)
.map(userMap => {
console.log(userMap["user1"]); // { id: 1, name: "John" }
});
  1. Modular Decoders: Break down complex decoders into smaller, reusable parts:

    const baseUserDecoder = JsonDecoder.object({...});
    const adminUserDecoder = baseUserDecoder.flatMap(user => ...);
    const regularUserDecoder = baseUserDecoder.flatMap(user => ...);
  2. 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');
  3. 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();
    });