Without assistance, the only thing TypeScript can prove about an argument is that it is a subtype of the parameter's annotation. Given a function (x: `foo${string}`) => void, the compiler will prove for all call sites that x is always a subtype of `foo${string}` and never an unrelated type. But it cannot prove properties outside of subtyping. It cannot directly prove that a string is exactly 6 characters long, that a string contains no spaces, that a type is non-union, or that a type satisfies many other useful properties.
The technique described here, which I call the type-validator pattern, allows us to teach the compiler to prove arbitrary properties about an argument's type at each call site, rejecting the call site if they do not hold.
Consider a simple event subscription system. Users subscribe to events by passing a dictionary of event handlers to a subscribe function. The set of valid event names is undefined, so the parameter type is Record<string, () => void>.
But let's say that subscribing to no events is a mistake and we want to reject that at compile time. No subtype constraint excludes {} while admitting valid dictionary types, {} extends Record<string, () => void> is always true. A type-validator will let us work around this limitation and prove non-subtyping properties at call time:
// The type-validator
type Nonempty<T extends Record<string, unknown>> =
{ [P in keyof T]: T }[keyof T];
declare function subscribe<T extends Record<string, () => void>>(
handlers: Nonempty<T>
): void;
subscribe({ onmessage: () => {} }); // OK
subscribe({}); // ErrorTo understand how, look at the three phases the compiler goes through when it type-checks a call-site:
- Inference. The compiler simplifies and infers all type parameters.
{ [P in keyof T]: T }[keyof T]simplifies to justT, which then unifies it with the argument type. Forsubscribe({}), it infersT = {}. - Instantiation. The compiler substitutes the inferred type parameters into the original function signature. The parameter instantiates as
handlers: Nonempty<{}>. Sincekeyof {}isnever, indexing byneverproducesnever. - Assignability check. The compiler checks whether the argument is assignable to the instantiated parameter type.
{}is not assignable tonever, so the call is rejected.
When the compiler checks the validity of a generic function call, it first needs to determine concrete parameter types. It cannot simply check each argument against the type parameter's constraints independently. Consider <T>(obj: T, key: keyof T). Checking key against the constraint of keyof T in isolation would only prove that it is some string | number | symbol, not that it's a valid key of the specific object passed as obj. The two parameters are interdependent through the type parameter T, so the compiler must determine T before it can check either argument. Instead, the compiler infers every type parameter first, before any arguments are checked against their parameter types.
To do this, it needs to determine the value of each type parameter. For each one, the compiler blindly traverses the parameter types looking for inference sites, positions where the type parameter appears and can be unified with the corresponding argument type. Inference is heuristic, the compiler has a few different strategies for finding inference sites, and failure is an option.
Before traversal begins, the compiler simplifies the parameter types:
a extends b ? U : Vsimplifies toUorVwhen the compiler can decidea extends bis always true or always false.{ [_ in K]: V }[K]simplifies toV(when not inferringK) by substituting the index for the mapping parameter in the value type.Kmust be generic (i.e. contain unresolved type parameters).[...T][number]simplifies to the union of element types inT
Then, for each type parameter, the compiler descends the parameter type (target) and the argument type (source) simultaneously. When a type parameter is found in the target, the corresponding position in the source is used for inference.
Object types and tuples are descended into. Inference sites found within them are unified with the source at the same structural position.
Conditional types that weren't simplified away earlier have both branches visited. The compiler infers from the source to both branches. The condition itself is opaque to inference and only evaluated later at instantiation time.
Mapped types where the constraint is a bare type parameter { [P in K]: V } infer keyof source into K. If K has no constraint that leads to further mapped type inference, the compiler also infers from the union of the source's property types into V, so other type parameters there are found.
Homomorphic mapped types { [P in keyof T]: V } are treated as an inference site for T itself. The compiler inverts the mapping to deduce T from the source. If you need both T and some other type parameter in V to be inferred, then it must have an inference site elsewhere.
[!NOTE] Context-sensitive arguments When a call site contains context-sensitive function literals, inference runs in two passes:
- First pass. Context-sensitive function literals are replaced with a placeholder ignored by inference. Type parameters are inferred from the remaining inference sites.
- Second pass. The compiler re-runs inference without replacing function literals. The type parameters inferred in the first pass are used to provide contextual types for the function literals. Any type parameter used in contextual typing is locked on first access to the value inferred in the first pass, but unused parameters are re-inferred.
declare function subscribe<T extends Record<string, (ev: Event) => void>>( handlers: Nonempty<T> ): void; subscribe({ onmessage(ev) { console.log(ev.type) }, // ev is contextually typed as Event });In the first pass,
onmessageis a placeholder. In the second pass,evis contextually typed asEventand the complete object literal type is inferred forT.
The compiler substitutes the inferred types into the parameter type and fully evaluates the result. This is where the validator's logic actually executes. For example, a validator that checks string length:
type ExactLength<T extends string, N extends number, Acc extends readonly unknown[] = [], S extends string = T> =
[T] extends [never] ? T : // inference site
Acc['length'] extends N
? T extends "" ? S : `${S} is too long`
: T extends `${string}${infer Rest}`
? ExactLength<Rest, N, readonly [...Acc, unknown], S>
: `${S} is too short`;
declare function example<T extends string, N extends number>(
s: ExactLength<T, N>,
l: N
): void;
example("abcdefghi", 6); // Error
example("abcdef", 6); // OK
example("abc", 6); // ErrorFor example("abc", 6), inference assigns T = "abc", N = 6, and instantiation proceeds thus:
example<"abc", 6>(s: ExactLength<"abc", 6>, l: 6): voids: ExactLength<"bc", 6, [_]>s: ExactLength<"c", 6, [_, _]>s: ExactLength<"", 6, [_, _, _]>s: "abc is too short"
example<"abc", 6>(s: "abc is too short", l: 6): void
The compiler checks whether each argument is assignable to its instantiated parameter type:
"abc"is not assignable to `"abc is too short": rejected"abcdef"is assignable to"abcdef": accepted"abcdefghi"is not assignable to"abcdefghi is too long": rejected
A validator that returns T on success passes trivially, the argument is assignable to itself. A validator that returns an incompatible type on failure causes the check to fail.
A validator instantiates to a type compatible with the argument on success and an incompatible type on failure. The most simple form is a conditional that returns T or never:
type Validator<T> =
/* type-level logic */
? T // pass: T is always compatible with itself
: never; // fail: nothing is compatible with neverThe success type does not have to be T, any type the argument is compatible with will do (but if you don't return T you will need to provide an alternate inference site). The failure type can be never, a descriptive string literal, a nominal Error<"message"> type, or anything else the argument won't match. A conditional type is also not required, as Nonempty<T> demonstrates, any type expression that produces a compatible or incompatible type based on T will work.
The extends bound on the type parameter and the validator serve different roles. The bound constrains inference to produce reasonable candidates. The validator runs at instantiation and enforces the real constraint that subtyping alone cannot express. Both can contribute to autocompletion: the bound provides suggestions before inference completes, and the instantiated validator type can suggest valid alternatives after.
The inference algorithm and the validation logic share the same parameter type. The compiler needs to find T somewhere in the parameter type to infer it, but the validation logic should be opaque during inference and only execute at instantiation. Since inference traverses conditional branches without evaluating conditions, the general trick is to place T in a branch that inference can see but that instantiation will never take, while the validation logic goes in the other branch.
Place T in the false branch of a condition that is always true at instantiation:
type Validate<T> =
T extends T ? /* validation logic */ : T;T extends T is always true at instantiation (it distributes, checking each union member against itself). Inference finds T in the false branch. The validation logic in the true branch executes for every union member.
Use [T] extends [never] to guard an inference site:
type Validate<T> =
[T] extends [never] ? T : /* validation logic */;The tuple wrapper prevents distribution. [T] extends [never] is only true when T is never, so the true branch is effectively unreachable. Inference finds T there. This is a common preamble for recursive validators that need to terminate on never.
Place T in the true branch of a condition that is always false at instantiation:
type Validate<T, Unused> =
T extends Unused ? T : /* validation logic */;Unused must be a separate type parameter that is always disjoint from T at instantiation, otherwise the true branch executes and the validation logic is bypassed. It must have a constraint, an unconstrained Unused defaults to extends unknown, which is a supertype of everything, making the true branch always reachable.
This is what Nonempty<T> uses. The compiler simplifies { [P in keyof T]: T }[keyof T] to T during inference, exposing it as an inference site. But the validation happens at instantiation when the indexed access is fully evaluated.
Intersect T with a type that reduces to never on failure:
declare function covary<T extends Record<string, { name: string }>>(
map: T & { [P in keyof T]: { name: P } }
): void;Inference finds T directly on the left. After instantiation, the right side becomes a concrete mapped type. If a key's name doesn't match its key, the intersection produces a property typed as never, and the argument fails the assignability check.
Mapped types can be used as inference sites. Two forms have special inference behavior:
A homomorphic mapped type { [P in keyof T]: X } infers T via reverse mapping, inverting the template to work backwards from the source's properties to deduce what T must be. Validation can be baked into the template: any property that fails becomes never, and the argument fails the assignability check for that specific property. This gives per-property errors rather than a single whole-argument failure.
A mapped type { [P in K]: X } where K is a bare type parameter (not keyof T) infers keyof source into K. If K is further constrained (e.g., K extends keyof T), the compiler also follows that constraint and performs homomorphic inference for T.
Recursive validators destructively decompose T (e.g., consuming characters from a template literal or elements from a tuple). A defaulted parameter preserves the original:
type Validate<T, Pass = T> =
/* recursive logic that decomposes T, returning Pass on success */;Pass captures T before recursion begins. The logic decomposes T freely and returns Pass on success. Without this, the base case would return whatever T has been reduced to (e.g., "" after consuming all characters).
The distributive conditional T extends T iterates over each union member individually. A second copy of T (captured via a defaulted parameter) retains the full union for comparison:
type NonUnion<T, U extends T = T> =
(T extends T ? U extends T ? 0 : 1 : never) extends 0 ? T : never;When T distributes, each member checks U extends T where U is the full union and T is a single member. If T is "a" | "b", then for member "a", "a" | "b" extends "a" is false, producing 1. The outer extends 0 fails.
A mapped type { [P in T]: unknown } produces different shapes depending on T:
- Literal or union of literals (
"foo","a" | "b"): produces an object with concrete keys ({ foo: unknown },{ a: unknown; b: unknown }). - Template literal pattern (
`foo${string}`): produces a pattern index signature. - Wide
string: produces a string index signature.
{} extends any index signature but not an object with required keys, so:
type IsTemplateLiteral<T extends string> =
{} extends { [P in T]: unknown } ? never : T;This passes for "foo" and "a" | "b" (concrete keys, {} is not assignable) and fails for string and `foo${string}` (index signatures, {} is assignable).
Template literal types can convert numbers to strings for pattern matching. `${T}` extends `${bigint}` rejects fractional numbers, Infinity, and NaN:
type IsInteger<T extends number> =
`${T}` extends `${bigint}` ? T : { error: `${T} is not an integer` };This works because bigint template literals match the same string patterns as integers ("1", "-7", "100") but not "5.5", "Infinity", or "NaN".
Sign can be detected by stringifying with a leading minus: `-${T}` extends `-${number}` is true only when T is negative. Note that -0 is not observable in the type system; its literal type is 0.
TypeScript's excess property checking only applies to fresh object literals, not to variables. A validator can reject excess properties structurally:
type Exact<Base extends object, T extends Base> =
T & { [P in Exclude<keyof T, keyof Base>]?: never };Properties in T that aren't in Base become optional never, which means they must be absent. Any excess property fails the assignability check.
When a validator fails, returning the set of valid alternatives instead of never lets the IDE suggest corrections:
type ValidateSort<T, U extends string> =
U extends `${keyof T & string}${"" | " asc" | " desc"}` ? U : `${keyof T & string}${"" | " asc" | " desc"}`;On failure, the validator returns the union of valid sort strings. TypeScript's error message includes this type, and the IDE offers its members as completions.
The easiest way to make a validator reject is to resolve to never. It's usually not compatible with the input type, but it doesn't explain the error at all. And sometimes never can be passed by accident, causing a real error to become silent when never is assigned to never.
A better alternative is to return a string literal. If the acceptance type is not string-like then there's no way for the user to pass in a matching literal. But if the acceptance type is a string, then the error message will be suggested by autocomplete and could be accidentally passed. I try to use a template literal in those cases containing the original string, for example ${T} is not camelCase works because even if the user attempts to pass that literal string, the rejected string will telescope out and remain incompatible.
Less commonly, the failure type can be a wrapper object like { error: "T must not be a union" } or you can declare a nominal class like declare class TypeError<M> { #private: M } and use TypeError<"your message">. The private field makes the class nominal, so it cannot be constructed or accidentally matched by a structural type.
Finally, the failure type can be the set of valid alternatives. If the validator can determine what the argument should have been, returning that type will let the IDE report the expected type in the error and offer autocompletion.