Guide

Reflection

Reflect any type into a RunType node, get its stable id, and walk the graph.

Reflection is RunTypes handing your TypeScript back to you at runtime. You can get a short, stable id for any type, or reflect it into a RunType: a small, traversable node the build recovered from your source. Validation, serialization and mocks all read this same graph, and nothing stops you from reading it too. There is one node per type, each tagged with a kind and linked to the inner types it holds.

A stable id for any type

getRunTypeId hands you a short, stable id for any type. Bring the type yourself, or let it infer the type from a value you already have. Either way you get the same id back.

import {getRunTypeId} from '@ts-runtypes/core';

// Static form — you bring the type, you get its id. No value needed.
const stringId = getRunTypeId<string>(); // e.g. "Sq3kZ1"
const userId = getRunTypeId<{id: number; name: string}>();

// Reflection form — T is inferred from a value. The value is only read for
// its type; at runtime it's ignored, so nothing leaks into the output.
const order = {id: 1, total: 42};
const orderId = getRunTypeId(order);

// Same shape in, same id out — getRunTypeId<{id: number}>() and a value of
// that shape resolve to the exact same string.
export {stringId, userId, orderId};

The id is a fingerprint of the type's shape, so two types that look the same share one id. Use it as a cache key, a registry key, a discriminator, or whatever you need.

These run at build time. With the Vite plugin off there's no id to inject, so getRunTypeId throws rather than guess. The id is filled in by a compiler marker.

Getting a node

getRunType<T>() returns the node for a type. Pass the type, or pass a value and let it be inferred. The compiler markers fill in the id at build time, so the call just works.

// Pass the type (or a value, and it is inferred) to get the node.
const orderRT = getRunType<Order>();

console.log(orderRT.kind === RunTypeKind.objectLiteral); // true: an object shape
console.log(orderRT.children?.map((prop) => prop.name)); // ['id', 'total', 'items']

// Drill into one property: its `child` is that property's own type.
const itemsRT = orderRT.children?.find((prop) => prop.name === 'items');
console.log(itemsRT?.child?.kind === RunTypeKind.array); // true: items is an array
console.log(itemsRT?.child?.child?.kind === RunTypeKind.objectLiteral); // true: of {sku, qty}

What is on a node

Every node has a kind, one of the RunTypeKind values. The rest depends on that kind, but the edges that hold inner types are always one of these:

  • child is the single inner type, like an array's element, a promise's value, or a property's type.
  • children are the inner types of a container, like an object's properties, a union's members, or a tuple's slots.
  • parameters and return are a function's argument nodes and its return node.

Around them you will find name and optional on members, literal and values for literals and enums, and formatAnnotation for a branded TypeFormat like an email or a bounded date.

When a node carries a formatAnnotation, its name is the canonical format name (uuid, email, dateTime, and so on) and its optional params holds that format's constraints. If you map a reflected format to something outside RunTypes, a database column or a UI label, key off typeFormats, the runtime table of every built-in format name paired with the base kind it refines. Both typeFormats and the FormatName union of its names are importable from the package root, so you read the name off the node and look it up there, with no second copy of the names to keep in sync.

Those names are faithful to your source, tuple element labels and function parameter names included. Two tuples with the same element types but different labels are distinct reflected types, each carrying its own labels, so a label or parameter name you read off a node is always the one written for that exact type.

Walking every kind

Because the shape is regular, a full walk is just a switch over kind that recurses through those edges. Here is a printer that renders any node back to a TypeScript-like string. It is the same dispatch createMockDataFn runs internally, one arm per kind: leaves return on the spot, single-child kinds recurse through child, containers through children, and callables through parameters and return.

// Render any node back to a TypeScript-like string. Every kind is one switch
// arm, the same way createMockDataFn dispatches over the graph internally: leaves
// return on the spot, single-child kinds recurse through `child`, containers
// through `children`, and callables through `parameters` and `return`.
function describe(rt: RunType): string {
  switch (rt.kind as number) {
    // Atomic leaves: no inner types to walk into.
    case RunTypeKind.string:
      return 'string';
    case RunTypeKind.number:
      return 'number';
    case RunTypeKind.boolean:
      return 'boolean';
    case RunTypeKind.bigint:
      return 'bigint';
    case RunTypeKind.symbol:
      return 'symbol';
    case RunTypeKind.null:
      return 'null';
    case RunTypeKind.undefined:
      return 'undefined';
    case RunTypeKind.void:
      return 'void';
    case RunTypeKind.never:
      return 'never';
    case RunTypeKind.any:
      return 'any';
    case RunTypeKind.unknown:
      return 'unknown';
    case RunTypeKind.object:
      return 'object';
    case RunTypeKind.regexp:
      return 'RegExp';
    case RunTypeKind.templateLiteral:
      return 'string'; // a `${...}` template
    case RunTypeKind.literal:
      return JSON.stringify(rt.literal); // the value itself
    case RunTypeKind.enum:
      return (rt.values as unknown[]).map((value) => JSON.stringify(value)).join(' | ');

    // Single-child kinds: recurse into `child`.
    case RunTypeKind.array:
      return `${describe(rt.child as RunType)}[]`;
    case RunTypeKind.promise:
      return `Promise<${describe(rt.child as RunType)}>`;
    case RunTypeKind.rest:
      return `...${describe(rt.child as RunType)}[]`;
    case RunTypeKind.tupleMember:
      return `${describe(rt.child as RunType)}${rt.optional ? '?' : ''}`;

    // Multi-child containers: recurse over `children`.
    case RunTypeKind.tuple:
      return `[${(rt.children as RunType[]).map(describe).join(', ')}]`;
    case RunTypeKind.union:
      return (rt.children as RunType[]).map(describe).join(' | ');
    case RunTypeKind.intersection:
      return (rt.children as RunType[]).map(describe).join(' & ');
    case RunTypeKind.objectLiteral:
      return `{ ${(rt.children as RunType[]).map(describe).join('; ')} }`;

    // Named members: `name`, an `optional` flag, and the member's own `child`.
    case RunTypeKind.property:
    case RunTypeKind.propertySignature:
    case RunTypeKind.parameter:
      return `${rt.name as string}${rt.optional ? '?' : ''}: ${describe(rt.child as RunType)}`;

    // An index signature pairs a key type with a value type.
    case RunTypeKind.indexSignature:
      return `[key: ${describe(rt.index as RunType)}]: ${describe(rt.child as RunType)}`;

    // Callables: recurse over `parameters` and `return`.
    case RunTypeKind.function:
    case RunTypeKind.method:
    case RunTypeKind.methodSignature:
    case RunTypeKind.callSignature:
      return `(${(rt.parameters as RunType[]).map(describe).join(', ')}) => ${describe(rt.return as RunType)}`;

    // A class either lists members (a plain shape) or stops at its name
    // (the builtins Date, Map, Set, Temporal, which carry a `subKind` instead).
    case RunTypeKind.class:
      return rt.children ? `{ ${(rt.children as RunType[]).map(describe).join('; ')} }` : String(rt.typeName ?? 'object');

    // typeParameter, infer, ref, enumMember: rare in plain data shapes.
    default:
      return `/* kind ${rt.kind} */`;
  }
}

const orderRT = getRunType<Order>();
console.log(describe(orderRT));
// { id: string; total: number; items: { sku: string; qty: number }[]; status: "open" | "shipped" | "cancelled" }
Builtin classes (Date, Map, Set, Temporal) stop at the class node. They carry a subKind and a name instead of walkable children, so a generic walk can treat them as leaves. Detect them by subKind (for example node.subKind === RunTypeSubKind.date), not by name, since one of your own classes could also be called Date. The RunTypeKind and RunTypeSubKind constant maps are both importable from the package root.

Every kind

A node's kind is always one of these, grouped by how you walk it:

Atomic (leaves)    string  number  boolean  bigint  symbol  null  undefined  void
                   never  any  unknown  object  regexp  literal  templateLiteral  enum
Single child       array  promise  rest                      (read child)
Containers         tuple  union  intersection  objectLiteral  (read children)
Members            property  propertySignature  parameter  tupleMember  indexSignature
Callables          function  method  callSignature  methodSignature  (read parameters, return)
Class              class, covering Date / Map / Set / Temporal or a plain shape (read subKind)
Type level (rare)  typeParameter  infer  enumMember  ref
Copyright © 2026