branded types
How detail() and fault() brand a plain status number so the compiler can compute each code's metadata requirement, covering Carries, Fault, Bare, Args, and Faults in depth.
The problem a brand solves
A registry's values are number. If detail/fault just returned number, the type system
would have nowhere to record "this particular 429 also carries { retryAfter: number }}", so
every code would look identical to every other at the type level, indistinguishable from a bare
status. Branding attaches that extra information to the type of the number, invisibly, with
zero runtime cost.
The examples on this page share one registry:
const REGISTRY = {
NOT_FOUND: 404,
RATE_LIMITED: detail<{ retryAfter: number }>(429),
STORAGE_FAILED: fault<{ cause?: string }>(500),
} as const satisfies Record<string, number>
const kit = createErrorKit('AppError', REGISTRY)
const AppError = kit.ErrorClass
const throwAppError = kit.throwError
namespace Brand {
type Carries<M extends object> = number & { readonly __carries: M }
type Fault = number & { readonly __fault: true }
}
__carries and __fault are never actually present on the value at runtime, since detail(429)
returns the plain number 429. The intersection type is a compile-time-only marker; casting the
return value (status as Brand.Carries<M>) is what attaches it, and nothing reads __carries
back except other types in this same module.
function detail<M extends object = never>(status: number): Brand.Carries<M> {
return status as Brand.Carries<M>
}
function fault(status: number): Brand.Fault
function fault<M extends object>(status: number): Brand.Carries<M> & Brand.Fault
detail's M defaults to never, not object. This matters: if it defaulted to object,
detail(429) with no type argument would silently accept any metadata shape at every call
site. Defaulting to never means a bare detail(429) (no <M> given) behaves like a code that
carries nothing, the same as writing the plain number 429, so there is no accidental "carries
anything" state.
Reading a brand back out: Brand.MetaOf
type MetaOf<S> = S extends Carries<infer M> ? M : Record<never, never>
Conditional-type inference is how the metadata shape gets back out of the branded number.
Record<never, never>, not {}, is the "nothing" case: unlike {}, which any object
structurally satisfies, Record<never, never> combined with HasRequired (below) is what lets
the type system tell a bare code apart from one whose metadata happens to be all-optional.
HasRequired<T>: does this shape demand anything?
type HasRequired<T> = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]
For each key, this resolves to that key's name if the key is required (its value type does not
include undefined), or never if it's optional. Indexing the resulting object type by
keyof T collects every required key's name into a union, which is never itself if there
were none. [HasRequired<Meta<R, C>>] extends [never] (wrapped in tuples so a never union
doesn't distribute away) is the test Bare and Args both use to ask "does this code's
metadata have at least one required field?"
Bare<R>: codes that take no arguments
type Bare<R extends Registry> = {
[C in Code<R>]: [HasRequired<Meta<R, C>>] extends [never] ? C : never
}[Code<R>]
A union of every code whose metadata has no required keys, which includes both genuinely bare
codes (NOT_FOUND: 404) and codes whose metadata is present but entirely optional
(detail<{ reason?: string }>(400)). Exported so you can write a helper that only accepts codes
safe to raise with zero context:
function quickFail(code: ErrorKit.Bare<typeof REGISTRY>) {
return throwAppError(code)
}
quickFail('NOT_FOUND') // fine
quickFail('RATE_LIMITED') // Error: 'RATE_LIMITED' is not assignable to Bare<typeof REGISTRY>
Args<R, C>: the constructor's actual parameter list
type Args<R extends Registry, C extends Code<R>> =
R[C] extends Brand.Carries<any>
? [HasRequired<Meta<R, C>>] extends [never]
? [meta?: Meta<R, C>]
: [meta: Meta<R, C>]
: []
Three possible shapes, resolved per code:
| Registry entry | Args<R, C> | Constructor call |
|---|---|---|
CODE: 404 (plain number) | [] | new AppError('CODE') |
CODE: detail<{ reason?: string }>(400) | [meta?: { reason?: string }] | new AppError('CODE') or new AppError('CODE', { reason: 'x' }) |
CODE: detail<{ retryAfter: number }>(429) | [meta: { retryAfter: number }] | new AppError('CODE', { retryAfter: 60 }), required |
The outer check (R[C] extends Brand.Carries<any>) is gated on the value's brand, not on
whether Meta<R, C> happens to be non-empty, since a bare code's Meta resolves to
Record<never, never>, which is structurally {}, and {} is satisfied by any object. Gating
on Meta instead of the brand would make every bare code silently accept an arbitrary metadata
argument.
Faults<R>: which codes represent your own storage layer
type Faults<R extends Registry> = { [C in Code<R>]: R[C] extends Brand.Fault ? C : never }[Code<R>]
A union of every code branded with fault, as opposed to detail. fault and detail behave
identically at runtime, and this type is the entire point of the distinction. A concrete use: a
retry policy that only retries fault codes, since a detail-branded code (a validation
rejection, a business-rule refusal) will not succeed on retry, while a fault-branded one (a
transient storage failure) might:
const FAULT_CODES: readonly ErrorKit.Faults<typeof REGISTRY>[] = ['STORAGE_FAILED']
function isRetryable(
err: InstanceType<typeof AppError>,
): err is InstanceType<typeof AppError> & { code: ErrorKit.Faults<typeof REGISTRY> } {
return (FAULT_CODES as readonly string[]).includes(err.code)
}
isRetryable. Faults<R> only gives you the type. Building the matching
runtime check (as above) is on you, because only you know your registry's actual fault codes.Next
- Error codes and status design: when to reach for
faultvsdetailin practice - API reference: the full signature list