Make TypeScript errors readable with template-literal constraint messages. Use when constraining literal object keys or string shapes and the error should point to the offending value.
62
73%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.agents/skills/type-level-error-messages/SKILL.mdRelated Skills: See
typescriptfor general TypeScript conventions. Seearktypefor runtime regex validation and where this pattern surfaces in arktype itself.
// 1. Predicate on the literal type.
type IsSnakeCase<S extends string> = /* recursive template literal walk */;
// 2. Branded error type. The trailing is a zero-width space.
type InvalidKey<S extends string> =
`Invalid action key "${S}", must be snake_case ASCII matching /^[a-z][a-z0-9_]*$/`;
// 3. Helper function. The mapped type returns the value when the key is valid,
// or the branded error when it isn't. The error is a string but it is not
// assignable to the value type, so TS surfaces the message at the bad property.
export function defineActions<T extends Record<string, Action>>(
actions: {
[K in keyof T & string]: IsSnakeCase<K> extends true
? T[K]
: InvalidKey<K>;
},
): T {
return actions;
}A consumer writing a bad key sees:
error TS2322: Type 'Action' is not assignable to type
'Invalid action key "tabs.close", must be snake_case ASCII matching /^[a-z][a-z0-9_]*$/'.That reads as an English sentence pointing at the exact problem.
Append (Unicode zero-width space, U+200B) to the error message string. Invisible in IDE tooltips, but it brands the type so it is structurally distinct from any plain string a user could type. Two reasons:
Invalid action key "...", TypeScript's autocomplete might suggest that literal string as a valid value. The U+200B makes the brand a non-typeable string, so autocomplete stays clean.S extends string. Other type machinery that narrows against arbitrary string subtypes can otherwise accidentally accept the error message. The U+200B prevents accidental matches.This is exactly what @ark/util's internal ErrorMessage<message> does:
// @ark/util/out/errors.d.ts
export type ErrorMessage<message extends string = string> =
`${message}${ZeroWidthSpace}`;We replicate it locally rather than reach into @ark/util (not part of arktype's public surface).
The recursive template-literal walk is the load-bearing part. For snake_case (/^[a-z][a-z0-9_]*$/):
type Lower =
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
| 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type WordChar = Lower | Digit | '_';
type IsTail<S extends string> = S extends ''
? true
: S extends `${WordChar}${infer Rest}`
? IsTail<Rest>
: false;
type IsSnakeCase<S extends string> = S extends `${Lower}${infer Rest}`
? IsTail<Rest> extends true
? true
: false
: false;Adapt the character set for your constraint: kebab-case, slug, semver, hex, etc.
arkregexarkregex parses regex patterns at the type level and infers narrowed template-literal types (e.g. regex('^ok$', 'i') infers 'ok' | 'oK' | 'Ok' | 'OK'). For character classes like [a-z], it intentionally falls back to string to avoid combinatorial explosion. Verified empirically:
import { regex } from 'arkregex';
const snake = regex('^[a-z][a-z0-9_]*$');
// ^? Regex<string, ...> <- falls back to string
snake.test('tabs.close'); // compiles fine; runtime would throwFor character-class patterns, write the recursive template literal by hand.
never or a string subtype?| Choice | Tooltip reads |
|---|---|
never | Type 'X' is not assignable to type 'never'. (useless) |
string | No error at all (any value is assignable) |
Branded object: { __invalid: S } | Property '__invalid' is missing in type 'X' but required in type ... (reads as missing property, not "bad key") |
| Branded template literal | Type 'X' is not assignable to type 'Invalid action key "tabs.close"...'. (reads as English) |
The branded template literal wins on readability.
unique symbol brand?A unique symbol works for nominal types on objects (type Brand<T, K> = T & { [tag]: K }), but it does not compose with template literal types. You cannot put a symbol inside Invalid key "${S}". The whole point of this pattern is that the error message IS the brand. Hiding it behind [tag]: unique symbol loses the message.
Compile-time validation only catches authoring inside the helper's parameter context. Authors can bypass it with:
Object.fromEntries(dynamic): TS widens key type to string, the predicate becomes vacuous.as cast: explicit bypass..js file in a mixed codebase.So pair the type-level check with a runtime check inside the helper:
export const ACTION_KEY_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
export function defineActions<T extends Record<string, Action>>(
actions: {
[K in keyof T & string]: IsSnakeCase<K> extends true ? T[K] : InvalidKey<K>;
},
): T {
for (const key of Object.keys(actions)) {
if (!ACTION_KEY_PATTERN.test(key)) {
throw new Error(`Invalid action key "${key}".`);
}
}
return actions as T;
}Two sources of truth (the template literal type and the regex) for the same rule. The cost is unavoidable: TypeScript cannot derive a runtime value from a type, and arkregex does not narrow [a-z] to a template literal. Keep both definitions in the same file, close together.
Result extends string// WRONG: InvalidKey<S> IS extends string, so this conditional always returns T[K].
type Validated<S extends string> = IsSnakeCase<S> extends true ? S : InvalidKey<S>;
type Param<T> = { [K in keyof T & string]: Validated<K> extends string ? T[K] : Validated<K> };Both branches of Validated<S> produce a string type (S is a string; InvalidKey<S> is a template literal). Check the predicate directly:
type Param<T> = {
[K in keyof T & string]: IsSnakeCase<K> extends true ? T[K] : InvalidKey<K>;
};<T extends Record<string, V>>If TypeScript picks T = Record<string, V> (the widest match), keyof T = string. The predicate IsSnakeCase<string> evaluates to false, so every key gets typed as InvalidKey<string>, which rejects everything.
Fix: rely on TS's homomorphic mapped-type inference. The mapped type { [K in keyof T & string]: ... } makes T inferable from the argument's literal keys. Use an explicit T extends Record<string, V> constraint (so misuse is caught) but trust the inference for the actual key narrowing.
T = ... widening case in testsWhen you write a test that passes a Record<string, V> (e.g. via as cast or a dynamic object), the type check at the call site will reject it. Cast through the helper's parameter type to exercise the runtime branch:
const dynamic = { 'bad.key': v } as unknown as Parameters<typeof defineActions>[0];
expect(() => defineActions(dynamic)).toThrow(/Invalid action key/);The error message is also rendered in CI logs. Keep it ASCII so the message reads everywhere. The U+200B is the only non-ASCII character; it is invisible.
ErrorMessage definition: node_modules/@ark/util/out/errors.d.ts:25.packages/workspace/src/shared/actions.ts (defineActions, IsSnakeCaseKey, InvalidActionKey).docs/articles/<date>-type-level-error-messages.md.ecba024
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.