JSON Round Trip
Any JavaScript data value you send over the wire comes back as the same value, including bigint, Date, URL, Map and Set. createJsonEncoderFn<T>() returns a function that turns a value into a JSON string. createJsonDecoderFn<T>() returns a function that turns the string back into your type.
const toJson = createJsonEncoderFn<Order>();
const fromJson = createJsonDecoderFn<Order>();
const json = toJson(order) as string;
// {"id":"o-1","total":"4999","placedAt":"2024-05-01T10:00:00.000Z","tags":["gift"]}
const fromJsonOrder = fromJson(json);
// total is a bigint, placedAt is a Date, tags is a Set
Date, bigint, Map or unions. When nothing needs converting, no code is written and the function is marked isNoop (no operation).Function Calls Over the Wire
Any parameter or return value a function declares survives the JSON round trip. You can call a function on another machine with the same values you would pass locally.
import {createJsonEncoderFn, createJsonDecoderFn} from '@mionjs/run-types';
// a function the client calls over the network
function scheduleOrder(
total: bigint,
deliverOn: Date,
tags: Set<string>
): Map<string, Date> {
return new Map([[`${total}-${[...tags].join()}`, deliverOn]]);
}
type Params = Parameters<typeof scheduleOrder>;
type Result = ReturnType<typeof scheduleOrder>;
// client: encode the params
const encodeParams = createJsonEncoderFn<Params>();
const body = encodeParams([
4999n,
new Date('2024-05-01T00:00:00.000Z'),
new Set(['gift']),
]) as string;
// '["4999","2024-05-01T00:00:00.000Z",["gift"]]'
// server: decode the params, same values as sent
const decodeParams = createJsonDecoderFn<Params>();
const [total, deliverOn, tags] = decodeParams(body); // bigint, Date, Set<string>
const result = scheduleOrder(total, deliverOn, tags);
// the return value makes the same trip back
const encodeResult = createJsonEncoderFn<Result>();
const decodeResult = createJsonDecoderFn<Result>();
const received = decodeResult(encodeResult(result) as string); // Map<string, Date>
export {received};
What the JSON Looks Like
Types that JSON supports stay as they are. The others become strings or arrays, and the decoder converts them back.
interface Meetup {
id: bigint;
startsAt: Date;
guests: Map<string, number>;
tags: Set<string>;
site: URL;
slot: [string, string?];
note?: string;
}
const encodeMeetup = createJsonEncoderFn<Meetup>();
encodeMeetup({
id: 42n,
startsAt: new Date('2024-05-01T10:00:00.000Z'),
guests: new Map([['ana', 2]]),
tags: new Set(['work']),
site: new URL('https://example.com/meetup'),
slot: ['am', undefined],
});
// {"id":"42","startsAt":"2024-05-01T10:00:00.000Z","guests":[["ana",2]],"tags":["work"],"site":"https://example.com/meetup","slot":["am",null]}
- A missing optional property stays missing.
undefinedin an array or tuple becomesnull, so the positions stay the same.NaN,Infinityand-Infinitybecomenull, as withJSON.stringify.
Union Encoding
A union whose members are all plain JSON (strings, numbers, booleans, null) stays as it is. When a member needs converting, like a Date or a bigint, the value is written as [index, value]. The index tells the decoder which member to rebuild.
interface Setting {
level: number | null; // every member is plain JSON
expires: Date | string; // Date is not plain JSON
}
const encodeSetting = createJsonEncoderFn<Setting>();
encodeSetting({level: null, expires: new Date('2024-05-01T10:00:00.000Z')});
// {"level":null,"expires":[1,"2024-05-01T10:00:00.000Z"]}
encodeSetting({level: 3, expires: 'never'});
// {"level":3,"expires":[0,"never"]}
JSON Strategies
The strategy option sets what happens to properties your type does not declare. Use the same one on the encoder and the decoder.
const encodeClone = createJsonEncoderFn<Item>(); // same as {strategy: 'clone'}
encodeClone(item); // {"name":"pen","price":"5"} item is unchanged
const encodeCompact = createJsonEncoderFn<Item>(undefined, {
strategy: 'compact',
});
encodeCompact(item); // ["pen","5"] item is unchanged
const encodeMutate = createJsonEncoderFn<Item>(undefined, {strategy: 'mutate'});
encodeMutate(item); // {"name":"pen","price":"5","internalNote":"do not send"} item.price is now '5'
const body = '{"name":"pen","price":"5","isAdmin":true}';
const decodeClone = createJsonDecoderFn<Item>(); // same as {strategy: 'clone'}
decodeClone(body); // {name: 'pen', price: 5n}
const decodeMutate = createJsonDecoderFn<Item>(undefined, {
strategy: 'mutate',
});
decodeMutate(body); // {name: 'pen', price: 5n, isAdmin: true}
const decodeCompact = createJsonDecoderFn<Item>(undefined, {
strategy: 'compact',
});
decodeCompact('["pen","5"]'); // {name: 'pen', price: 5n}
| Strategy | Undeclared properties | Changes your value |
|---|---|---|
clone (default) | dropped | no, builds a new value |
mutate | kept | yes, converted in place |
compact | dropped | no |
- With
mutate, an undeclared property goes toJSON.stringifyuntouched. An undeclaredbigintmakes it throw. compactwrites each object as a smaller array of its values, in the order your type declares them.- With
compact, a missing optional property is written as the first of0,nullorfalseits type cannot hold. Sos?: string | nullkeeps a presentnull. An optional that can hold all three, likeanyorunknown, decodes a presentnullasundefined. - With
compact, a union of objects is written as one array holding every member's properties. Each value is wrapped with its member index only when another member is not a plain string, number, boolean ornull.
rejectCircularRefs included. A variable is ignored and the defaults apply.Checking Decoded Data
Decoders do not validate. They convert what they find and trust the shape. For data from outside your app, validate the decoded value with createValidateFn<T>().
import {
createJsonDecoderFn,
createValidateFn,
createGetValidationErrorsFn,
} from '@mionjs/run-types';
interface User {
id: string;
createdAt: Date;
}
const decodeUser = createJsonDecoderFn<User>();
const isUser = createValidateFn<User>();
const getUserErrors = createGetValidationErrorsFn<User>();
export function readUser(body: string) {
const user = decodeUser(body);
if (!isUser(user))
throw new Error(`Invalid user: ${JSON.stringify(getUserErrors(user))}`);
return user;
}
readUser('{"id":"u1","createdAt":"2024-05-01T10:00:00.000Z"}'); // ok, createdAt is a Date
readUser('{"id":"u1","createdAt":"yesterday"}'); // throws, createdAt is an invalid Date
null or a number stays as it is, so the validator rejects it.Values That Are Not Data
Functions, methods, symbols, promises, RegExp and typed arrays like Int8Array are not data. RunTypes handles them by position:
- As a property or a union member, they are left out. The build shows a warning.
- As the whole type, an array item or a tuple slot, the build fails with an error.
Decoders return DataOnly<T>, your type without those members.
import {createJsonDecoderFn, createJsonEncoderFn} from '@mionjs/run-types';
interface Account {
id: string;
balance: number;
describe(): string; // a method, so it is left out
}
const encodeAccount = createJsonEncoderFn<Account>();
const decodeAccount = createJsonDecoderFn<Account>();
const account: Account = {id: 'a1', balance: 10, describe: () => 'a1: 10'};
const json = encodeAccount(account) as string; // {"id":"a1","balance":10}
const decoded = decodeAccount(json);
// decoded type is {id: string; balance: number}, with no describe()
export {decoded};
Circular References
Recursive types work, like a tree or a linked list. A value that points back to itself does not: by default the loop ends in a RangeError from the JavaScript stack. Pass rejectCircularRefs: true to the JSON encoder to throw a CircularReferenceError instead, before anything is written, with the path to the loop.
import {createJsonEncoderFn, CircularReferenceError} from '@mionjs/run-types';
interface Category {
name: string;
parent?: Category;
}
const encodeCategory = createJsonEncoderFn<Category>(undefined, {
rejectCircularRefs: true,
});
const root: Category = {name: 'root'};
root.parent = root; // a value that points back to itself
try {
encodeCategory(root);
} catch (err) {
if (err instanceof CircularReferenceError) console.log(err.message, err.path);
// Circular reference detected at parent ['parent']
}
Classes
A class instance encodes like an object with the same properties, and decodes to a plain object by default. Register the class with registerClassSerializer from @mionjs/run-types/runtime to get real instances back.
Pass deserialize when the constructor takes arguments. Without it, RunTypes creates the instance and copies the properties in. Pass serialize to change what is written, keeping it to the declared properties.
import {createJsonEncoderFn, createJsonDecoderFn} from '@mionjs/run-types';
import {registerClassSerializer} from '@mionjs/run-types/runtime';
class Money {
constructor(
public amount: number,
public currency: string
) {}
format(): string {
return `${this.amount} ${this.currency}`;
}
}
// the constructor takes arguments, so say how to rebuild it
registerClassSerializer(Money, {
deserialize: (data) => new Money(data.amount, data.currency),
});
const encodeMoney = createJsonEncoderFn<Money>();
const decodeMoney = createJsonDecoderFn<Money>();
// decoded as data only, so cast to get the methods back
const price = decodeMoney(
encodeMoney(new Money(4999, 'USD')) as string
) as Money;
price instanceof Money; // true
price.format(); // '4999 USD'
Date, URL, Map and Set have their own handling and cannot be registered.Private Class Fields
private and protected fields are checked and encoded like public ones. # fields are left out.
tsc fails your build with marker-untyped-private-member, since its .d.ts drops the type of private fields. Build the package with mion compile, which keeps the type and writes the field as protected, or ship its TypeScript sources.Encoding Values Inside Your Own JSON
Use the value functions to put several typed values in one JSON document. They take the same strategy option, but leave JSON.stringify and JSON.parse to you.
import {
createPrepareForJsonFn,
createRestoreFromJsonFn,
} from '@mionjs/run-types';
interface Invoice {
id: bigint;
dueAt: Date;
}
interface Customer {
name: string;
since: Date;
}
const prepareInvoice = createPrepareForJsonFn<Invoice>();
const prepareCustomer = createPrepareForJsonFn<Customer>();
const restoreInvoice = createRestoreFromJsonFn<Invoice>();
const restoreCustomer = createRestoreFromJsonFn<Customer>();
// your own envelope, one JSON.stringify
const body = JSON.stringify({
invoice: prepareInvoice({
id: 7n,
dueAt: new Date('2024-06-01T00:00:00.000Z'),
}),
customer: prepareCustomer({
name: 'Ana',
since: new Date('2020-01-01T00:00:00.000Z'),
}),
});
// one JSON.parse, then restore each value
const envelope = JSON.parse(body);
const invoice = restoreInvoice(envelope.invoice); // id is a bigint, dueAt is a Date
const customer = restoreCustomer(envelope.customer); // since is a Date
export {invoice, customer};
| Function | What it does |
|---|---|
createPrepareForJsonFn<T>() | converts a typed value to a value JSON.stringify can write |
createRestoreFromJsonFn<T>() | converts a parsed value back to your type |
Typing the JSON Output
JSONShape<T> is the type of the JSON a RunTypes encoder writes for T. Use it to type data you build or receive in that form.
import type {JSONShape} from '@mionjs/run-types';
interface Order {
id: string;
total: bigint;
placedAt: Date;
tags: Set<string>;
}
type OrderJson = JSONShape<Order>;
// {id: string; total: `${bigint}`; placedAt: string; tags: string[]}
const fromAnotherService: OrderJson = {
id: 'o-1',
total: '4999',
placedAt: '2024-05-01T10:00:00.000Z',
tags: ['gift'],
};
export {fromAnotherService};
JSONShape<T> describes the output. Do not validate against it: validate your own type after decoding.