FriendlyText
Experimental The enrichment workflow works today. The generated file shape and the commands may still change between minor versions.
FriendlyText<T> gives every field of a type a label and error messages. You commit the map 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 read.
FriendlyText Shape
The map mirrors your type, every field included. Every node is { rt$label, rt$errors, ...fields }: both rt$ keys always, then one key per child field. Arrays use rt$items, tuples rt$slots, a Map rt$keys and rt$values, a Set rt$values.
import type * as TF from '@mionjs/run-types/formats';
// src/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 {FriendlyText} from '@mionjs/run-types';
import type {User} from './user';
// @todo: generated skeleton — fill in real data, then delete this line
export const friendlyUser: FriendlyText<User> = {
rt$label: '',
rt$errors: {type: ''},
name: {
rt$label: '',
rt$errors: {
type: '',
minLength: {one: '', other: ''},
maxLength: {one: '', other: ''},
},
},
age: {
rt$label: '',
rt$errors: {
type: '',
min: {one: '', other: ''},
max: {one: '', other: ''},
},
},
isActive: {rt$label: '', rt$errors: {type: ''}},
tags: {
rt$label: '',
rt$errors: {type: ''},
rt$items: {rt$label: '', rt$errors: {type: ''}},
},
profile: {
// nested object: same node shape
rt$label: '',
rt$errors: {type: ''},
email: {
rt$label: '',
rt$errors: {
type: '',
minLength: {one: '', other: ''},
maxLength: {one: '', other: ''},
pattern: '',
},
},
score: {
rt$label: '',
rt$errors: {
type: '',
min: {one: '', other: ''},
max: {one: '', other: ''},
},
},
},
};
import type {FriendlyText} from '@mionjs/run-types';
import type {User} from './user';
export const friendlyUser: FriendlyText<User> = {
rt$label: 'User account',
rt$errors: {type: '$[label] must be an object'},
name: {
rt$label: 'Full name',
rt$errors: {
type: '$[label] must be text',
minLength: '$[label] needs at least $[val] characters',
maxLength: '$[label] allows at most $[val] characters',
},
},
age: {
rt$label: 'Age',
rt$errors: {
type: '$[label] must be a number',
min: '$[label] must be at least $[val]',
max: '$[label] must be no more than $[val]',
},
},
isActive: {rt$label: 'Active?', rt$errors: {type: ''}},
tags: {
rt$label: 'Tags',
rt$errors: {type: ''},
rt$items: {rt$label: '', rt$errors: {type: 'each tag must be text'}}, // element node
},
profile: {
// nested object: same node shape, recursively
rt$label: 'Profile',
rt$errors: {type: ''},
email: {
rt$label: 'Email',
rt$errors: {
type: '',
minLength: '',
maxLength: '',
pattern: 'Enter a valid email address',
},
},
score: {
rt$label: 'Score',
rt$errors: {type: '', min: 'min $[val]', max: 'max $[val]'},
},
},
};
--require-complete still fails on it. Never delete a key: the next sync adds it back.Keeping FriendlyText in Sync
When your type changes, enrich --update re-syncs the map and keeps every label and message you wrote. Text an edit would throw away is kept as a comment:
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
};
| The edit | What happens to your text |
|---|---|
| Rename a field or a whole type | it follows the field, untouched |
| Remove a field | its node is kept as a comment marked @rtOrphanChild |
| Change a field's type | the label always survives, and only the messages the new type no longer declares are kept as comments |
| Change a field to a different shape (a number becomes an array of objects) | the whole old node is kept as a comment and a fresh blank takes its place |
| Delete a whole type | its entire map is kept as a comment marked @rtOrphan |
Pruning Comments Left by Removed Fields
Comments left by a removed field or type stay until a prune clears them all at once.
mion enrich --prune
@rtOrphan and @rtOrphanChild comments, never a live field or a @todo blank. Bring a removed field or type back and the next enrich --update restores its text.MockData<T> works the same: a removed field's sample pool is kept as a comment until enrich --prune.Error Message Keys
Each rt$errors key names the rule the value broke, and params that never fail (such as float, isCurrency and the transform block) get no key. Your editor accepts only keys the field's format can fail on, so a typo shows right away. mion enrich --no-emit warns about a missing key (enrich-text-missing-message) or one the field can never fail on (enrich-text-unknown-error-key), and --update adds missing keys as blanks.
| Failure | rt$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 (minimum / maximum) | min / max | the bound |
number lt / gt (exclusiveMaximum / exclusiveMinimum) | lt / gt | the bound |
number integer | integer | true |
| date or time in the wrong layout | format | the layout ('ISO') |
date-time date / time part | date / time | n/a |
uuid or IP version | version | '4' |
| credit card format or checksum | creditCard | n/a |
| credit card network not allowed | networks | the allowed list |
email address (EmailAddress, IdnEmail) | emailRfc | n/a |
email with no @ | @ | n/a |
host name (Hostname, IdnHostname) | idna | n/a |
domain label starts or ends with - | hyphen | n/a |
minLength and pattern gets one message per broken rule. For one message per field, use rt$default below.Message Placeholders
The compiler checks these $[…] tokens and the renderer fills them in.
| Token | Resolves 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 received value) is not supported yet: the error does not carry the input.Plural Messages
A count rule (minLength, maxLength, min, max, lt, gt) can hold one wording per plural form instead of a single string. The form follows the broken limit (the 3 in minLength: 3), and only other is required.
minLength: {
one: '$[label] needs at least $[val] character',
other: '$[label] needs at least $[val] characters',
},
The scaffold has the forms each file's language needs (English gets one and other, see Translations (i18n)). Other rules (type, pattern, and the rest) stay a single string, and a single string still works on a count rule when one wording fits every count.
One Message per Field
Make rt$default the only key in rt$errors and it becomes the message for every failure of that field. A node has either rt$default or the per-rule keys, never both, so each field picks its own mode. It is translatable like any other message.
import type * as TF from '@mionjs/run-types/formats';
import type {FriendlyText} from '@mionjs/run-types';
interface Signup {
name: TF.String<{minLength: 2; maxLength: 60}>;
}
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'},
},
};
rt$default by hand and every later sync keeps it.Rendering Messages
createFriendlyText<T>(map) returns a renderer with two methods. It needs only the map and the errors.
import {
createFriendlyText,
createGetValidationErrorsFn,
} from '@mionjs/run-types';
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, picks the message for each broken rule and fills in its 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 blank or missing message falls back to "