AI Integration

FriendlyType

Human-readable labels and error messages for a type.

FriendlyType<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 { $label?, $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 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 at scan time, so a stale key or a structural mismatch is a build-time Error.

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

// models/user.ts
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 $items for the element node; objects nest as the same node, recursively.

Error keys name the rule that failed

Each $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.

Failure$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 only get the keys your type allows. A plain name: string can only fail as type. You get minLength / maxLength because the field declares those rules. 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 data form yields one message per violated constraint (a list). To join them into a single sentence, reach for the function escape hatch below.

The placeholder DSL

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

TokenResolves to
$[label]the node's $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 $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.

The function escape hatch

Any $errors entry may be an inline arrow instead of a template record, for logic the data form can't express, such as joining constraints, pluralization, or i18n lookups. It receives a synthesized failed bag (this field's failures, grouped and keyed by the same constraint names), so one call yields one message per field:

name: {
  $label: 'Full name',
  $errors: (failed) => {
    const parts: string[] = [];
    if (failed.minLength) parts.push(`at least ${failed.minLength.val} characters`);
    if (failed.maxLength) parts.push(`at most ${failed.maxLength.val} characters`);
    return parts.length ? `Name must be ${parts.join(' and ')}` : 'Invalid name';
  },
},

The function must be an inline expression (no external reference); its body runs at runtime, so it can call i18n machinery freely. The trade-off: the data form gets compile-time placeholder and constraint validation; a function doesn't.

Rendering at runtime with createFriendly

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

import { createFriendly } from 'ts-runtypes';
import { userFriendly } from 'runtypes/generated/models/user';

const friendly = createFriendly<User>(userFriendly);

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

// errors(errs): render a createGetValidationErrors result into messages
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 $label, or its raw last path segment as fallback
  message: string;  // the interpolated message
}

A data-form $errors yields one message per violated constraint; a function-form $errors yields one message per field. When a field has no map entry, 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