Quick Start
1. Install
You need two things: the runtime package and the Vite plugin that reads your types.
pnpm add @ts-runtypes/core
pnpm add -D @ts-runtypes/devtools
npm install @ts-runtypes/core
npm install -D @ts-runtypes/devtools
yarn add @ts-runtypes/core
yarn add -D @ts-runtypes/devtools
@ts-runtypes/devtools does its work at build time and ships nothing to production.2. Wire up the Vite plugin
Add the plugin to your vite.config.ts. It runs the Go binary that does the type reading.
import {defineConfig} from 'vite';
import runtypes from '@ts-runtypes/devtools/vite';
// vite.config.ts. Add the plugin. The resolver binary for your platform is
// installed automatically (an optional dependency) and resolved for you.
export default defineConfig({
plugins: [
runtypes({
tsconfig: 'tsconfig.json',
}),
],
});
tsconfig.json flag to enable. Point the plugin at your existing tsconfig.json and it reads your types directly. (If you're coming from an older reflection library, this is the step you can skip.)3. Write a type and validate it
That's the whole setup. Now write a normal TypeScript type and ask for a validator. The build generates it for you.
import {createValidateFn, createGetValidationErrorsFn} from '@ts-runtypes/core';
// 1. Write a normal type.
type User = {
id: number;
name: string;
email: string;
roles: ('admin' | 'user')[];
};
// 2. Ask for a validator. The build generates it from `User`.
const isUser = createValidateFn<User>();
// 3. Use it. This is a real, specialized function: no runtime reflection.
const maybeUser: unknown = JSON.parse('{"id":1,"name":"Ada","email":"ada@x.io","roles":["admin"]}');
if (isUser(maybeUser)) {
// maybeUser is narrowed to User here.
console.log(maybeUser.name);
}
// Want the WHY, not just a yes/no? Reach for the error reporter.
const getUserErrors = createGetValidationErrorsFn<User>();
getUserErrors({id: '1', name: 'Ada'}); // [{path: ['id'], ...}, ...]
export {isUser, getUserErrors};
isUser is a real, specialized function. No schema, no decorators, no reflection at runtime, just a function that knows exactly what a User is.
Other bundlers
Vite is the shortest path, but the same plugin ships for Rollup, Rolldown, webpack, Rspack, esbuild, Bun and Next.js. Each one has its own entry point, and every entry takes the same options.
| Bundler | Import |
|---|---|
| Vite | @ts-runtypes/devtools/vite |
| Rollup | @ts-runtypes/devtools/rollup |
| Rolldown | @ts-runtypes/devtools/rolldown |
| webpack | @ts-runtypes/devtools/webpack |
| Rspack | @ts-runtypes/devtools/rspack |
| esbuild | @ts-runtypes/devtools/esbuild |
| Bun | @ts-runtypes/devtools/bun |
| Next.js | @ts-runtypes/devtools/next |
import runtypes from '@ts-runtypes/devtools/rollup';
// rollup.config.js. Same plugin and the same options as the Vite entry, imported
// from the Rollup entry point instead.
export default {
plugins: [
runtypes({
tsconfig: 'tsconfig.json',
}),
],
};
Bun
The Bun entry covers both of the ways Bun runs plugins.
For a bundle, pass it to Bun.build like any other plugin:
import runtypes from '@ts-runtypes/devtools/bun';
await Bun.build({
entrypoints: ['./src/index.ts'],
outdir: './dist',
plugins: [runtypes()],
});
To run your project directly with bun run or bun test, there is no bundle step, so the plugin registers on Bun's loader from a preload file instead. Point bunfig.toml at it and every file is compiled as Bun imports it:
preload = ["./rt-preload.ts"]
[test]
preload = ["./rt-preload.ts"]
// rt-preload.ts
import {plugin} from 'bun';
import runtypes from '@ts-runtypes/devtools/bun';
plugin(runtypes());
That is the whole setup. You can write await plugin(...) if you prefer, but you do not have to: the plugin makes sure no file is loaded before it is ready either way.
Next.js
Next.js needs its own entry because Turbopack, the bundler Next 16 uses by default, does not take plugins at all. Wrap your config and RunTypes handles the rest.
import {withRunTypes} from '@ts-runtypes/devtools/next';
export default await withRunTypes({
reactStrictMode: true,
});
The await matters. RunTypes reads your types before Turbopack starts compiling, and that has to finish first.
Options go in a second argument, the same ones every other entry takes:
export default await withRunTypes({reactStrictMode: true}, {tsconfig: 'tsconfig.json'});
One wrapper covers both bundlers, so next dev --webpack and next build --webpack keep working with no extra setup.
If your project is part of a monorepo, point Turbopack at the folder that holds your node_modules and any shared code you import. Turbopack refuses to compile files outside that folder:
import path from 'node:path';
export default await withRunTypes({
turbopack: {root: path.resolve(import.meta.dirname, '..')},
});
The rewrite is identical whatever bundler runs it, so your generated code does not change when you switch. The Vite entry adds hot reload on top, and for a tool that is not on the list, @ts-runtypes/devtools/unplugin exposes the framework-agnostic unplugin instance the others are built from.
While your dev server is running, editing a type refreshes every file that uses it, even when the type lives in another file and even when nothing imports it (a type only import, or a global declared in a .d.ts). Your bundler cannot see those connections on its own, so RunTypes reports them for you.