AI Integration

FriendlyText

Human-readable labels and error messages for a type.

FriendlyText<T> is a human-readable map for a type, giving a label and error messages to each field. You author it once, commit it next to your type, and the compiler checks it against that type on every build. Use it to turn validation errors into messages people can actually read.

How it's shaped

The map mirrors your type. Every node is { rt$label, rt$errors, ...fields }. The $ keys hold the label and error messages for this field, and every other key is a child field. It nests the same way all the way down.

The map is total: every field of the type appears, and every node carries both rt$label and rt$errors. A blank string means "no custom text here" and the renderer falls back gracefully, so a blank is always safe to leave. What you never do is delete a key; the next sync simply scaffolds it back. One type maps to exactly one shape.

The compiler scaffolds the map from your type with every field in place and each blank marked @todo, then the agent fills those blanks. The map is validated against User both by TypeScript itself and at scan time, so a stale key or a structural mismatch is an error before anything runs.

import type * as TF from '@ts-runtypes/core/formats';

// models/user.ts — the source type every enrichment example derives from.
export interface User {
  name: TF.String<{minLength: 2; maxLength: 60}>;
  age: TF.Number<{min: 0; max: 120}>;
  isActive: boolean;
  tags: string[];
  profile: {
    email: TF.Email;
    score: TF.Number<{min: 0; max: 100}>;
  };
}

Container meta keys follow the type's shape: arrays and tuples use rt$items for the element node; objects nest as the same node, recursively.

When your type changes, nothing you wrote is lost

The map is committed next to your type and lives through every edit. As the type changes, enrich --update re-syncs the map and keeps every label and message you authored.

When you rename a field or a whole type, your text follows it automatically. There is nothing to refill:

// you rename `name` to `fullName` in your type;
// the label moves with it, untouched:
fullName: { rt$label: 'Full name', rt$errors: { type: '$[label] must be text' } },

When an edit would instead throw text away, the map keeps it as a comment rather than deleting it, so you can recover it or remove it on your own terms. Three edits do this.

Remove a field, and its node is kept as a comment marked @rtOrphanChild:

export const friendlyUser: FriendlyText<User> = {
  rt$label: 'User account',
  rt$errors: { type: '' },
  name: { rt$label: 'Full name', rt$errors: { type: '' } },
  /* @rtOrphanChild isActive: { rt$label: 'Active?', rt$errors: { type: '' } }, */   // you removed isActive
};

Change a field's type, and the old node is parked while a fresh blank takes its place (its old messages may no longer fit the new type):

// age: number  becomes  age: string
  /* @rtOrphanChild age: { rt$label: 'Age', rt$errors: { type: '', min: '…', max: '…' } }, */
  age: { rt$label: '', rt$errors: { type: '' } }, // @todo

Delete a whole type, and its entire map is kept as a comment marked @rtOrphan:

/* @rtOrphan
export const friendlyAddress: FriendlyText<Address> = {
  rt$label: 'Mailing address',
  rt$errors: { type: '' },
  city: { rt$label: 'City', rt$errors: { type: '' } },
}; */

The file grows until you prune it

These parked comments are a safety net, so they stay until you clear them. They add up as you iterate, one comment per removed field or type, and editing the same field several times leaves a comment for each old version. That is expected, and two things keep it tidy.

Run enrich --prune to clear every parked comment in one pass. It removes only the @rtOrphan and @rtOrphanChild comments, never a live field and never a @todo blank:

ts-runtypes enrich --prune

And if a field or type you removed comes back, its parked text is restored for you on the next enrich --update, so a quick delete and undo costs nothing.

The same parking and pruning applies to MockData<T>: a removed field's sample pool is kept as a comment, and enrich --prune clears it. Your authored data is never dropped, only parked or restored.

Error keys name the rule that failed

Each rt$errors key names a specific rule the value broke: minLength, pattern, min, and so on. type is the catch-all for a value that's the wrong kind entirely (text where a number was expected). The keys aren't made up; they line up exactly with what the validator reports.

Failurert$errors key$[val] resolves to
base type-shape (wrong kind)typen/a
string minLength / maxLengthminLength / maxLengththe bound (2, 60)
string patternpatternn/a
number min / maxmin / maxthe bound
number lt / gtlt / gtthe bound
number integerintegertrue
date/time date / timedate / timen/a
uuid versionversion'4'

You get exactly the keys your type allows, and all of them. A plain name: string can only fail as type. Declare minLength and maxLength on the field and both keys become part of the node: TypeScript requires each one (a blank string is the "no custom message" answer) and rejects any key the field cannot fail, so a typo or a leftover key from an old rule shows up in your editor, not at build time. Presentation and transformer params (isCurrency, lowercase, trim, and friends) never fail validation, so they never become keys. The richer the type, the richer the messages you can write.

Errors accumulate. A value that violates both minLength and pattern produces two failures, not one, so the per-constraint form yields one message per violated constraint (a list). To show a single sentence for the whole field instead, use the rt$default mode below.

The placeholder DSL

Templates are plain strings with $[…] tokens the renderer substitutes and the compiler validates:

TokenResolves to
$[label]the node's rt$label, falling back to the raw field name
$[val]the failed constraint's bound (e.g. 2 for minLength: 2)
$[path]the dotted path to the field (profile.email)
$[index]the array element index, for rt$items failures
$[value] (the actual received value) is out of scope for v1: the error carries no input value, so threading it into the renderer is deferred.

Plural messages

A count-carrying rule (minLength, maxLength, min, max, lt, gt) can hold a small object instead of a single string, one wording per plural form. The generator scaffolds that object for you with the forms your language actually uses (an English map gets one and other), and you fill in the strings:

minLength: {
  one: '$[label] needs at least $[val] character',
  other: '$[label] needs at least $[val] characters',
},

The form is picked at runtime from the violated limit (the 3 in minLength: 3) using the browser's plural rules; other is the only required form. Every other rule (type, pattern, and the rest) stays a single string, and a single string on a count-carrying rule is still fine when one wording covers every count.

Plural objects are also the unit of translation: each locale's file carries exactly the forms that language needs. See Translations (i18n).

rt$default: one message for the whole field

Sometimes a field does not need a message per rule; one sentence covers everything. Write rt$default as the node's only key and it becomes the message for every failure of that field:

import type * as TF from '@ts-runtypes/core/formats';
import type {FriendlyText} from '@ts-runtypes/core';

interface Signup {
  name: TF.String<{minLength: 2; maxLength: 60}>;
}

// rt$default as the node's ONLY key: one message for every failure of the field.
export const friendlySignup: FriendlyText<Signup> = {
  rt$label: 'Signup',
  rt$errors: {type: ''},
  name: {
    rt$label: 'Full name',
    rt$errors: {rt$default: 'Enter a name between 2 and 60 characters'},
  },
};

The two modes are mutually exclusive on a node: rt$errors holds either the per-constraint record or the single rt$default, never a mix. Each node picks its own mode, so a form can use rt$default for simple fields and per-constraint messages where precision matters. Because rt$default is plain data, it stays fully translatable (see Translations).

New nodes are always scaffolded in the per-constraint mode. To use a single catch-all instead, rewrite the field to rt$default by hand; once a node exists its authored mode is yours and every later sync follows it.

Rendering at runtime with createFriendlyText

Rendering needs nothing but the map and the errors. createFriendlyText<T>(map) returns a renderer with two methods:

import {createFriendlyText, createGetValidationErrorsFn} from '@ts-runtypes/core';
import type {User} from './user';
import {friendlyUser} from './friendly-user';

const getUserErrors = createGetValidationErrorsFn<User>();
const friendly = createFriendlyText<User>(friendlyUser);

// label(path): dotted string or a raw path-segment array
friendly.label('profile.email');
// → 'Email'

// errors(errs): render a createGetValidationErrorsFn result into messages
const badInput: unknown = {name: 'A', age: 200, profile: {email: 'nope'}};
friendly.errors(getUserErrors(badInput));
// → [{ path: 'profile.email', label: 'Email', message: 'Enter a valid email address' }, …]

errors() groups failures by path, walks each path into the map, picks the template for the failed constraint, and fills in the $[…] tokens. Each result is a FriendlyMessage:

interface FriendlyMessage {
  path: string;     // dotted path to the field ('profile.email'); '' for the root
  label: string;    // the field's rt$label, or its raw last path segment as fallback
  message: string;  // the interpolated message
}

A per-constraint rt$errors yields one message per violated constraint; a rt$default node yields its one message for each failure. Whenever a template is blank or missing, the renderer falls back gracefully, using the raw field name as the label and a generic "is invalid" message.

Error rendering works today. Form-building UI is coming later. Listing every field of a type to build a form needs the type's reflection data, which RunTypes already exposes; pairing the two is a small future addition.
Copyright © 2026