ts.data.json helps you validate JSON data at runtime with compile-time type safety. This guide will show you how to use the library effectively.
Let's start with the basics. Here's how to decode simple JSON values:
import * as JsonDecoder from 'ts.data.json';
// String decoder
const nameDecoder = JsonDecoder.string();
nameDecoder.decode('John'); // Ok({ value: 'John' })
nameDecoder.decode(123); // Err({ issues: [{ message: '123 is not a valid string', path: [] }] })
// Number decoder
const ageDecoder = JsonDecoder.number();
ageDecoder.decode(25); // Ok({ value: 25 })
ageDecoder.decode('25'); // Err({ issues: [{ message: '"25" is not a valid number', path: [] }] })
// Boolean decoder
const isActiveDecoder = JsonDecoder.boolean();
isActiveDecoder.decode(true); // Ok({ value: true })
isActiveDecoder.decode('true'); // Err({ issues: [{ message: '"true" is not a valid boolean', path: [] }] })
Decode against a TypeScript enum with enumeration. A rejected value is
formatted with JSON.stringify, just like the other primitive decoders, so the
quoting depends on the value's type:
enum Color {
Red = 'red',
Blue = 'blue'
}
const colorDecoder = JsonDecoder.enumeration<Color>(Color);
colorDecoder.decode('red'); // Ok({ value: 'red' })
// A string value keeps its quotes...
colorDecoder.decode('green');
// Err({ issues: [{ message: '"green" is not a valid enum value', path: [] }] })
// ...while a numeric value is rendered without quotes.
enum Priority {
Low = 1,
High = 2
}
const priorityDecoder = JsonDecoder.enumeration<Priority>(Priority);
priorityDecoder.decode(3);
// Err({ issues: [{ message: '3 is not a valid enum value', path: [] }] })
Most of the time, you'll work with objects. Here's how to decode them:
// Define your type
interface User {
id: number;
name: string;
email: string;
age?: number; // Optional field
}
// Create a decoder
const userDecoder = JsonDecoder.object<User>({
id: JsonDecoder.number(),
name: JsonDecoder.string(),
email: JsonDecoder.string(),
age: JsonDecoder.optional(JsonDecoder.number())
});
// Valid data
const validJson = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
age: 30
};
const user = await userDecoder.decodePromise(validJson);
console.log(`Hello ${user.name}!`); // Hello John Doe!
// Invalid data -- all field errors are reported at once
const invalidJson = {
id: 'not-a-number',
name: 'John Doe',
email: 'john@example.com'
};
try {
await userDecoder.decodePromise(invalidJson);
} catch (error) {
console.log(error.message); // 'id: "not-a-number" is not a valid number'
}
For complex objects with nested structures:
interface Address {
street: string;
city: string;
country: string;
}
interface User {
id: number;
name: string;
address: Address;
}
// Create decoders for nested structures
const addressDecoder = JsonDecoder.object<Address>({
street: JsonDecoder.string(),
city: JsonDecoder.string(),
country: JsonDecoder.string()
});
const userDecoder = JsonDecoder.object<User>({
id: JsonDecoder.number(),
name: JsonDecoder.string(),
address: addressDecoder // Use the nested decoder
});
const json = {
id: 1,
name: 'John Doe',
address: {
street: '123 Main St',
city: 'Boston',
country: 'USA'
}
};
console.log(
await userDecoder.decodePromise(json).then(user => `${user.name} lives in ${user.address.city}`) // John Doe lives in Boston
);
Decoding arrays of values:
// Array of strings
const tagsDecoder = JsonDecoder.array(JsonDecoder.string());
tagsDecoder.decode(['typescript', 'json', 'decoder']); // Ok({ value: ["typescript", "json", "decoder"] })
tagsDecoder.decode(['typescript', 123, 'decoder']);
// Err({ issues: [{ message: '123 is not a valid string', path: [1] }] })
// Array of objects
const usersDecoder = JsonDecoder.array(userDecoder);
await usersDecoder
.decodePromise([
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
])
.then(users => users.map(user => user.id)); // [1, 2]
usersDecoder.decode([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
]);
// Err({ issues: [{ message: 'undefined is not a valid string', path: [0, 'email'] }] })
Use fallback to provide fallback values:
const numberOrZero = JsonDecoder.fallback(0, JsonDecoder.number());
numberOrZero.decode('not a number'); // Ok({ value: 0 })
You can use other strategies combining other decoders:
const statusDecoder = JsonDecoder.oneOf([
JsonDecoder.literal('active'),
JsonDecoder.literal('inactive'),
JsonDecoder.constant('unknown') // always succeeds with 'unknown'
]);
statusDecoder.decode('inactive'); // Ok({ value: 'inactive' })
statusDecoder.decode('zxytwqgtyb'); // Ok({ value: 'unknown' })
The library uses a Result type to handle success and failure cases safely:
const myUserResult: JsonDecoder.Result<User> = userDecoder.decode(validUserJson);
const uppercasedUserEmail: JsonDecoder.Result<string> = myUserResult
.map(user => {
return user.email;
})
.map(email => {
return email.toUpperCase();
});
// isOk() is a type guard
if (uppercasedUserEmail.isOk()) {
console.log(uppercasedUserEmail.value); // JOHN@EXAMPLE.COM
}
When a decode fails, the Err result holds an issues array. Each entry contains a human-readable message and a path pointing to the failing field:
const result = userDecoder.decode({ id: 'bad', name: 42, email: 'john@example.com' });
if (!result.isOk()) {
result.issues.forEach(issue => {
const location = issue.path.length > 0 ? JsonDecoder.formatIssuePath(issue.path) : 'root';
console.log(`${location}: ${issue.message}`);
// id: "bad" is not a valid number
// name: 42 is not a valid string
});
}
You can use the FromDecoder type to infer types from decoders:
import { FromDecoder } from 'ts.data.json';
const userDecoder = JsonDecoder.object({
id: JsonDecoder.number(),
name: JsonDecoder.string(),
email: JsonDecoder.string()
});
// Instead of manually defining the User interface:
type User = JsonDecoder.FromDecoder<typeof userDecoder>;
// type User = { id: number; name: string; email: string }
Everything above uses the main entry point, where a decoder is a class instance and you call methods on it. There is a second entry point, ts.data.json/mini, where a decoder is a plain function and the methods are standalone imports:
import * as J from 'ts.data.json/mini';
const userDecoder = J.object({
id: J.number(),
name: J.string()
});
J.decode(userDecoder, json); // instead of userDecoder.decode(json)
J.parse(userDecoder, json); // instead of userDecoder.parse(json)
J.map(J.string(), s => s.trim()); // instead of .map(s => s.trim())
Both entry points validate identically and report the same issues. mini exists to drop the class wrapper, which saves about 1 kB minified: roughly 450 bytes instead of 1450 for a single decoder. Use it when you are counting bytes, and the main entry point when you prefer the chained method style.
Decoders from the two entry points are different types and cannot be mixed, so pick one per project.
Reuse Decoders: Create reusable decoders for common patterns:
const numToStringDecoder = JsonDecoder.number().map(n => n.toString(10));
numToStringDecoder.decode(123); // Ok({ value: "123" })
const dateDecoder = JsonDecoder.string().flatMap(...);
const emailDecoder = JsonDecoder.string().flatMap(...);
Type Safety: Let TypeScript help you by using type annotations and inference:
const myDecoder = JsonDecoder.object(...);
type User = JsonDecoder.FromDecoder<typeof myDecoder>;