type-level-error-messages

star 4.6k

Make compile-time errors readable by branding constraint-violation types as template literal messages with a U+200B zero-width-space suffix. Use when writing helper functions that constrain object keys, string shapes, or other literal-type inputs and you want the TypeScript error tooltip to read as an English sentence pointing at the offending value.

EpicenterHQ By EpicenterHQ schedule Updated 6/8/2026

name: type-level-error-messages description: Make compile-time errors readable by branding constraint-violation types as template literal messages with a U+200B zero-width-space suffix. Use when writing helper functions that constrain object keys, string shapes, or other literal-type inputs and you want the TypeScript error tooltip to read as an English sentence pointing at the offending value. metadata: author: epicenter version: '1.0'

Type-Level Error Messages

Related Skills: See typescript for general TypeScript conventions. See arktype for runtime regex validation and where this pattern surfaces in arktype itself.

When to Apply This Skill

Use this pattern when you need to:

  • Constrain object keys, string literals, or other literal-type inputs in a helper function and surface a readable error at the call site.
  • Replace a never or string return in a constraint type with something that points at the bad value.
  • Match the way arktype's internal ErrorMessage<M> types behave: visible message, invisible brand.
  • Give app authors edit-site feedback on shape/format rules (snake_case keys, slug formats, semver strings) without forcing every consumer to call a separate validate() step.

The pattern in one example

// 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.

Why the U+200B suffix

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:

  1. Autocomplete hygiene. Without the brand, if anywhere in the codebase the inferred type happens to be 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.
  2. Distinct from 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).

Anatomy of a constraint predicate

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.

Why not arkregex

arkregex 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 throw

For character-class patterns, write the recursive template literal by hand.

Why not 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.

Why not a 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.

Where to put the runtime check

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.
  • Helper called from a .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.

Common pitfalls

Mistake 1: checking 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>;
};

Mistake 2: declaring the helper as <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.

Mistake 3: missing the T = ... widening case in tests

When 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/);

Mistake 4: emoji or em-dash in the error message

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.

Reference

  • arktype's ErrorMessage definition: node_modules/@ark/util/out/errors.d.ts:25.
  • Our example use: packages/workspace/src/shared/actions.ts (defineActions, IsSnakeCaseKey, InvalidActionKey).
  • Background article: docs/articles/<date>-type-level-error-messages.md.
Install via CLI
npx skills add https://github.com/EpicenterHQ/epicenter --skill type-level-error-messages
Repository Details
star Stars 4,632
call_split Forks 351
navigation Branch main
article Path SKILL.md
More from Creator