Guide

Source Conversion

Rewrite files between plain types and type builders with one command.

One shape, any notation

RunTypes accepts two ways to write a shape: a plain TypeScript type and the value-first type builders. They are interchangeable, so a codebase can also switch between them. The convert command rewrites your files from one form to the other, in place.

# one file, or several
npx ts-runtypes convert --to builders src/models.ts src/api.ts

# or a whole folder (every .ts and .tsx inside it)
npx ts-runtypes convert --to type src/models/

The targets are type and builders. Declarations already written in the target form are left byte-for-byte unchanged, so running the command twice is safe.

// A file you might have today, written type-first.
export type User = {
  id: number;
  name?: string;
  tags: string[];
};

After --to builders the declaration reads like this. The type name survives as an alias, so every import of it keeps working. (The example below renames the alias only so both spellings can sit in one file; the real command keeps your original name.)

// The same declaration after `ts-runtypes convert --to builders`: the const
// carries the shape, and the alias keeps the type name alive so nothing that
// imported `User` breaks.
export const userRT = RT.object({id: TF.number(), name: RT.optional(TF.string()), tags: RT.array(TF.string())});
export type UserAsBuilders = InferType<typeof userRT>;

Types written inside a call

Not every type has a declaration. Writing one straight into a factory call is just as common, and there is nothing there for the converter to rename, so it rewrites the call itself.

// A type written straight into a factory call has no declaration to rewrite,
// so the converter rewrites the call itself. Before:
export const isOrder = createValidateFn<{id: string; total: number}>();

Converting to type builders moves the type into the value slot the same factory already accepts:

// And after converting to type builders, the same call with the type as a
// value. It reflects the same shape, so it is the same validator.
export const isOrderBuilt = createValidateFn(RT.object({id: TF.string(), total: TF.number()}));
getRunTypeId<{id: string; total: number}>() === getRunTypeId(RT.object({id: TF.string(), total: TF.number()})); // true

Two shapes are deliberately left alone. A call that already names its type (createValidateFn<Order>()) keeps the name, because the declaration behind it converts on its own. And a call that reflects a runtime value (createValidateFn(order)) is untouched, since there is no written type there to move.

Identity never moves

Conversion changes the spelling, never the shape. Every converted declaration resolves to the same identity as before, which means the same generated validator, the same codecs, the same mock data. This is enforced, not aspirational: the converter's test suite converts randomly generated files through the full chain (type to builders and back) and checks the identity of every declaration on every step.

// Conversion never moves a type's identity: both spellings resolve to
// the same id, so they share one generated validator, codec and mock pool.
getRunTypeId<User>() === getRunTypeId(userRT); // true

What converts

Everything the engine reflects: primitives and literals, string and number formats, arrays, tuples (labeled ones included, using RT.slot in builder form), objects (optional, readonly and quoted keys included), records (including an index signature beside named properties, which becomes an intersection in builder form), unions, enums, classes, Date, Map, Set, Promise, RegExp, Temporal types, functions (named parameters use RT.slot too), template literals, branded types, and recursive types. Recursive shapes come out as RT.circular with RT.self() in builder form.

Declarations that reference each other stay references. A type that names another converted type keeps the name in every form, across files too: convert the files together and the imports are updated for you.

npx ts-runtypes convert --to builders src/user.ts src/order.ts

What does not convert

Most shapes that the builder form has no word for still convert: they ride an escape that carries the type itself, getRunType<T>(). That covers functions, template literals, bigint literals and more.

What is left is the short list below, where even the escape cannot help because the type cannot be written down inside it. The converter never guesses: it reports the shape, leaves that declaration exactly as you wrote it, converts the rest of the file, and exits with a non-zero code so CI notices.

ShapeWhy
A cycle that never passes through a named typeThe self reference has nothing to point at. Give the inner type a name and it converts.
A symbol-keyed propertyThe property name is a symbol, and an escape can only carry a type it can write down.
A recursive type reached only inside an embedded type expressionEmbedded types are quoted TypeScript, and quoted text cannot point back at the declaration being defined.
A type that loops back through one of its own tuple slots, like type Pair = [number, Pair]Only when converting to type builders. TypeScript works out a tuple's slots straight away rather than on demand, so the builder form cannot close the loop there. Put the recursion behind an object, array, Map, Set or function and it converts.
A reference to a type whose file is not in the same runAdd that file to the same command.
A Temporal type that resolves to anyThe Temporal declarations are missing from your project. Add the ESNext.Temporal lib or a polyfill's types. Converting would bake the any in permanently.

A generic type (type Box<T> = ...) is not on the list, and not an error either: a type parameter has no runtime shape, so there is nothing to convert. The declaration is reported and left as written, and every place it is used with real types converts normally.

That list is kept honest by a test. Every row is a real declaration handed to the real command, asserting the exact message you would see and that your file came back untouched: unsupported-conversion.test.ts. When one of these starts converting, that test fails and the row goes away, so the list can never quietly drift from the tool.

One more case is not a refusal but worth knowing: converting to plain types leaves a builder const alone if your code still uses it elsewhere, since removing it would break those call sites.

Checking without writing

--check reports what would change and exits with code 1 while anything is pending, so it slots into CI. --out-dir writes the converted files into a copy of the folder and leaves your sources untouched.

npx ts-runtypes convert --to type --check src/models/
npx ts-runtypes convert --to builders src/models/ --out-dir converted/

Anything the converter cannot express is reported with a clear message and left exactly as it was, and the exit code tells you something needs attention. The shapes that can happen to are listed above.

Copyright © 2026