Guide

JSON Round Trip

Turn typed values into JSON and back, with Dates, bigints, Maps and Sets intact.

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
The build only writes code for what JSON does not support directly, like 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};
This is how mion RPC sends the params and return value your routes declare, with no schema to write.

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.
  • undefined in an array or tuple becomes null, so the positions stay the same.
  • NaN, Infinity and -Infinity become null, as with JSON.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"]}
Only a RunTypes decoder for the same type can read the index. If another tool reads your JSON, keep union members to plain JSON values.

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}
StrategyUndeclared propertiesChanges your value
clone (default)droppedno, builds a new value
mutatekeptyes, converted in place
compactdroppedno
  • With mutate, an undeclared property goes to JSON.stringify untouched. An undeclared bigint makes it throw.
  • compact writes 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 of 0, null or false its type cannot hold. So s?: string | null keeps a present null. An optional that can hold all three, like any or unknown, decodes a present null as undefined.
  • 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 or null.
Write the options as a literal object in the call, 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
A decoder only converts the exact form the encoder writes. A Date field holding 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'
One registration covers every generic version of the class. 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.

A class from an installed package built with plain 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};
FunctionWhat 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.
Copyright © 2026