FriendlyType
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 }>;
};
}
import type { FriendlyType } from 'ts-runtypes';
import type { User } from './user';
// scaffolded by `gen`: every field in place, each blank marked @todo
export const userFriendly: FriendlyType<User> = {
$label: '', // @todo
name: {
$label: '', // @todo
$errors: {
type: '', // @todo
minLength: '', // @todo
maxLength: '', // @todo
},
},
age: {
$label: '', // @todo
$errors: {
type: '', // @todo
min: '', // @todo
max: '', // @todo
},
},
isActive: { $label: '' }, // @todo
tags: {
$label: '', // @todo
$items: { $errors: { type: '' } }, // @todo element node
},
profile: { // nested object: same node shape
$label: '', // @todo
email: { $label: '', $errors: { pattern: '' } }, // @todo
score: { $label: '', $errors: { min: '', max: '' } }, // @todo
},
};
import type { FriendlyType } from 'ts-runtypes';
import type { User } from './user';
export const userFriendly: FriendlyType<User> = {
$label: 'User account',
name: {
$label: 'Full name',
$errors: {
type: '$[label] must be text',
minLength: '$[label] needs at least $[val] characters',
maxLength: '$[label] allows at most $[val] characters',
},
},
age: {
$label: 'Age',
$errors: {
type: '$[label] must be a number',
min: '$[label] must be at least $[val]',
max: '$[label] must be no more than $[val]',
},
},
isActive: { $label: 'Active?' },
tags: {
$label: 'Tags',
$items: { $errors: { type: 'each tag must be text' } }, // element node
},
profile: { // nested object: same node shape, recursively
$label: 'Profile',
email: { $label: 'Email', $errors: { pattern: 'Enter a valid email address' } },
score: { $label: 'Score', $errors: { min: 'min $[val]', max: 'max $[val]' } },
},
};
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) | type | n/a |
string minLength / maxLength | minLength / maxLength | the bound (2, 60) |
string pattern | pattern | n/a |
number min / max | min / max | the bound |
number lt / gt | lt / gt | the bound |
number integer | integer | true |
date/time date / time | date / time | n/a |
uuid version | version | '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.
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:
| Token | Resolves 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.