Status: Brainstorm / discussion draft
Goal: Explore a TypeScript-compatible way to make type-level programming look and behave more like ordinary TypeScript.
TypeScript already has a powerful type-level programming language.
It supports branching through conditional types, local bindings through infer, iteration through recursion, pattern matching through conditional inference, and higher-order abstractions through a variety of encodings. It is powerful enough to support substantial compile-time computation—and, famously, even Doom.
The problem is not a lack of power. The problem is that this power is expressed indirectly.
Type-level programs are currently written as recursive aliases, nested conditional types, distributive behavior, and clever uses of infer. These encodings are difficult to read, debug, profile, and optimize.
This proposal starts from one design principle:
The type language should be another evaluation domain of TypeScript, not a separate programming language hidden inside it.
Instead of adding isolated type-system features, TypeScript could introduce explicit type functions that reuse familiar TypeScript syntax while operating over type-level values.
type function Flatten(values: readonly unknown[]) {
return values.flatMap((item) => {
if (item is readonly unknown[]) {
return item;
}
return [item];
});
}The goal is not merely to make the type system more powerful. It is to make its existing power ordinary.
Today, flattening a tuple might be expressed as:
type Flatten<T> =
T extends readonly [infer Head, ...infer Tail]
? Head extends readonly unknown[]
? [...Flatten<Head>, ...Flatten<Tail>]
: [Head, ...Flatten<Tail>]
: [];This works, but it requires the reader to understand several mechanisms simultaneously:
- recursive type aliases;
- conditional types;
- tuple decomposition;
- variadic tuple construction;
infer;- termination through a base case;
- distributive behavior and how to suppress it.
The equivalent runtime algorithm is straightforward:
function flatten(values: readonly unknown[]) {
return values.flatMap((item) =>
Array.isArray(item) ? item : [item]
);
}The type-level version should be similarly readable.
type function Flatten(values: readonly unknown[]) {
return values.flatMap((item) => {
if (item is readonly unknown[]) {
return item;
}
return [item];
});
}One is encoded as a proof. The other is written as an algorithm.
TypeScript already distinguishes between constructs evaluated at runtime and constructs erased after checking.
This proposal makes that phase distinction explicit:
function parse(input: string) {
// Runs at runtime.
}
type function Parse(input: string) {
// Runs during type evaluation.
}The syntax and programming model remain familiar. The evaluation domain changes.
A useful mental model is:
functionevaluates runtime values;type functionevaluates type-level values;- ordinary function types describe runtime functions;
type (...) => ...describes type functions.
type RuntimeMapper =
(value: unknown) => unknown;
type TypeMapper =
type (value: unknown) => unknown;This is intended as a superset of TypeScript. Existing generic aliases, conditional types, mapped types, and other constructs would continue to work.
A type function uses value-style parameters and invocation.
type function Pair(T: type) {
return readonly [T, T];
}
type StringPair = Pair(string);
// readonly [string, string]This separates two concepts that are currently both expressed through generic syntax:
- generic parameters quantify over possible inputs;
- function parameters receive type-level values.
For example:
type function identity<T>(value: T) {
return value;
}The generic <T> states that the function is polymorphic. The parameter (value: T) receives a type-level value of that type.
Invocation uses ordinary function-call syntax:
type Result = identity(string);Existing generic type constructors can retain their current syntax:
Array<string>
Promise<number>Type functions would not need to replace generic aliases. They would provide an explicit notation for type-level computation.
Type-function types mirror ordinary function types, with the type keyword selecting the evaluation domain.
type UnaryTypeFunction =
type (value: unknown) => unknown;Polymorphic type functions remain generic:
type PolymorphicMapper =
type <From, To>(value: From) => To;Higher-order type functions then become ordinary:
type function Map<T, U>(
values: readonly T[],
mapper: type (value: T) => U,
) {
return values.map(mapper);
}This also gives the language first-class type constructors without requiring a separate higher-kinded-type feature.
A constructor such as:
type function Optional(T: type) {
return T | undefined;
}has a type similar to:
type TypeConstructor =
type (T: type) => type;What is usually called an HKT becomes a normal higher-order function over type-level values.
TypeScript needs a type-domain equivalent of runtime predicates.
Reusing == for assignability is tempting, but assignability is directional while equality is usually understood as symmetric.
The is operator reads naturally and can drive control-flow narrowing:
if (item is readonly unknown[]) {
return item;
}Inside that branch, item is narrowed to the intersection of its previous type and readonly unknown[].
This is analogous to value-level narrowing:
if (typeof value === "string") {
value.toUpperCase();
}Type equality could remain distinct:
A === Bmeaning exact identity of type-level values, should such an operation be useful and well-defined.
Today, infer is often used as a substitute for a local binding:
type Normalize<T> =
Required<T> extends infer R
? keyof R extends infer K
? { keys: K; values: R[K & keyof R] }
: never
: never;A type function could use ordinary bindings:
type function Normalize(T: object) {
const required = Required<T>;
const keys = keyof required;
const values = required[keys];
return { keys, values };
}infer could return to its natural role: binding variables during pattern matching.
TypeScript currently models iteration through recursive type aliases.
type TupleMap<T extends readonly unknown[]> =
T extends readonly [infer Head, ...infer Tail]
? [Transform<Head>, ...TupleMap<Tail>]
: [];Explicit iteration communicates intent to both readers and the compiler:
type function TupleMap(values: readonly unknown[]) {
const result = [];
for (const value of values) {
result.push(Transform(value));
}
return result;
}For finite tuples, evaluation could preserve exact structure.
type Result = TupleMap([1, 2, 3]);
// [Transform(1), Transform(2), Transform(3)]For open arrays, the result could widen appropriately.
Explicit loops also give the compiler a better optimization target. It no longer needs to infer that a recursive conditional type is secretly performing a fold over a tuple.
Conditional types currently combine several ideas:
- branching;
- assignability testing;
- pattern matching;
- union distribution.
Those behaviors are useful, but they need not be inseparable.
type function Classify(T: type) {
if (T is string) {
return "string";
}
return "other";
}Union distribution could be explicit rather than an implicit property of a naked type parameter:
type function ClassifyUnion(T: type) {
return T.mapUnion((member) => Classify(member));
}The exact API is open for discussion, but explicit distribution would likely be easier to reason about than today's conditional-type rules.
If JavaScript or TypeScript gains value-level pattern matching, the same construct could be raised naturally into the type domain.
type function Head(T: readonly unknown[]) {
return match (T) {
when ([head, ...tail]) => head;
when ([]) => never;
};
}This would replace many uses of nested conditional types and infer with ordinary structural decomposition.
The broader principle is important:
When TypeScript gains a language feature, ask whether it can also operate in the type evaluation domain.
This makes the design extensible. The type language does not need a parallel collection of unrelated constructs.
Making type-level programs explicit would make ordinary debugging concepts available.
type function Flatten(T: readonly unknown[]) {
console.log(T);
return T.flatMap((item) => {
console.log(item);
if (item is readonly unknown[]) {
return item;
}
return [item];
});
}The compiler could display output during type evaluation:
Flatten([1, [2, 3], 4])
item = 1
item = [2, 3]
item = 4
=> [1, 2, 3, 4]
This suggests richer tooling:
- type-level breakpoints;
- stepping through type functions;
- inspecting local bindings;
- type-evaluation call stacks;
- profiling;
- execution traces;
- type-function test coverage.
Instead of:
Type instantiation is excessively deep and possibly infinite.
the compiler could report:
Type evaluation exceeded its recursion budget.
Flatten([1, [2, 3], 4])
→ flatMap callback for [2, 3]
→ Flatten([2, 3])
→ ...
Type-level programming currently has substantial execution machinery but almost no corresponding observability.
Explicit type functions could be easier to optimize than equivalent encodings.
Today, a checker sees a recursive combination of:
- conditional types;
- substitutions;
- inference variables;
- variadic tuples;
- mapped types;
- union distribution.
It must evaluate the encoding without necessarily knowing the intended algorithm.
With explicit type functions, the compiler sees:
- a loop;
- a branch;
- a function call;
- a local binding;
- a tuple transformation.
That provides a more focused intermediate representation.
Potential implementation strategies include:
- Parse type functions as ordinary TypeScript-like syntax.
- Lower them into a restricted, pure type-evaluation IR.
- Evaluate symbolic type values rather than JavaScript runtime values.
- Enforce resource budgets and deterministic evaluation.
- Cache calls based on function identity and type-level arguments.
- Preserve existing checker behavior for compatibility.
The proposal does not require executing arbitrary JavaScript inside the checker. It requires a familiar syntax over a constrained, deterministic type-level evaluator.
The design can be described as:
JavaScript syntax, pure evaluation, types as values.
Type functions should likely be pure from the program's perspective.
They should not perform:
- file I/O;
- network access;
- environment inspection;
- arbitrary mutation of compiler state;
- time-dependent computation.
Operations such as console.log would be diagnostic effects observed by tooling, not general-purpose side effects that influence evaluation.
Purity enables:
- caching;
- parallel checking;
- deterministic builds;
- reproducible diagnostics;
- tractable reasoning about evaluation order.
Local mutation may still be useful as implementation syntax:
type function Reverse(T: readonly unknown[]) {
const result = [];
for (const item of T) {
result.unshift(item);
}
return result;
}Such mutation would remain internal to a single evaluation and would not create observable shared identity.
This proposal is intended as an additive extension.
Existing TypeScript remains valid:
type Flatten<T> =
T extends readonly [infer Head, ...infer Tail]
? ...
: ...;Libraries could adopt type functions gradually.
The compiler could initially lower type functions into existing checker primitives, or implement them through a dedicated evaluator while preserving interoperability with ordinary types.
Type functions should be callable from existing type contexts:
type Result = Flatten<Input>;Whether invocation uses Flatten(Input) exclusively or permits angle-bracket sugar is an open syntax question. Parentheses better communicate function evaluation and mirror the value language.
| Existing type-level mechanism | Proposed form |
|---|---|
| Conditional types | if / else |
infer used as a local variable |
const / let |
| Recursive tuple traversal | loops or recursion |
| Generic alias instantiation for computation | type-function calls |
extends as a branch predicate |
is |
| HKT encodings | first-class type functions |
| Nested conditional decomposition | pattern matching |
| Opaque checker expansion | stack traces and debugging |
| Implicit union distribution | explicit union operations |
This is not intended as a list of unrelated additions. These are consequences of treating the type system as another evaluation domain of TypeScript.
Current style:
type Awaited<T> =
T extends null | undefined
? T
: T extends object & {
then(onfulfilled: infer F, ...args: infer _): any;
}
? F extends (value: infer V, ...args: infer _) => any
? Awaited<V>
: never
: T;Possible type function:
type function Awaited(T: type) {
while (T is PromiseLike<infer Value>) {
T = Value;
}
return T;
}type function Exclude(T: type, U: type) {
return T.filterUnion((member) => !(member is U));
}type function Pick<T extends object>(
value: T,
keys: readonly (keyof T)[],
) {
const result = {};
for (const key of keys) {
result[key] = value[key];
}
return result;
}type function Map<T, U>(
values: readonly T[],
transform: type (value: T) => U,
) {
return values.map(transform);
}type function Optional(T: type) {
return T | undefined;
}
type function Apply<F extends type (T: type) => type>(
constructor: F,
value: type,
) {
return constructor(value);
}
type OptionalString = Apply(Optional, string);This is not primarily a proposal for:
- dependent types;
- theorem proving;
- a new runtime metaprogramming system;
- arbitrary compile-time JavaScript execution;
- replacing all existing TypeScript type syntax;
- maximizing theoretical type-system power.
It is a proposal to expose existing type-level computation through a more ordinary programming model.
The key question is not:
How can TypeScript's type system become more clever?
It is:
How can TypeScript programmers write, inspect, and debug the computations the checker already performs?
This draft deliberately leaves several questions unresolved.
Possible candidates include:
- ordinary types;
- unions;
- tuple structures;
- keys;
- literal types;
- type constructors;
- constraints;
- possibly kinds represented as function signatures.
A tuple can support exact symbolic iteration. An open array must generally produce a widened result.
The evaluator needs clear rules for when structure is preserved and when it is widened.
Current conditional types distribute implicitly in specific syntactic circumstances.
A type-function model might make union mapping, filtering, and folding explicit.
TypeScript's structural type system makes exact type identity less obvious than runtime value identity.
The language may need multiple relations:
- assignability;
- mutual assignability;
- normalized structural equality;
- internal identity.
The is operator only needs to address assignability and narrowing.
A generic alias:
type Pair<T> = [T, T];and a type function:
type function Pair(T: type) {
return [T, T];
}are mathematically similar.
They may remain separate because one is declarative and backwards-compatible while the other permits general computation.
TypeScript already limits recursive instantiation. Type functions would need explicit budgets for:
- recursion depth;
- loop iterations;
- allocated symbolic structure;
- total evaluation work.
Those limits should produce useful execution traces.
The type domain should probably begin with a restricted subset:
- local bindings;
- functions;
- calls;
- returns;
- branches;
- loops;
- destructuring;
- pure collection operations;
- pattern matching, if available;
- diagnostic logging.
The subset can grow as semantics and implementation experience mature.
Advanced TypeScript types are often described as magic or wizardry.
That reputation is partly deserved—not because the underlying ideas are necessarily obscure, but because ordinary algorithms must be encoded through mechanisms that were not designed as a conventional programming language.
A type-level parser, tuple transform, schema mapper, or API normalizer should be understandable as an algorithm.
Lowering the barrier will produce more type-level code, including more bad type-level code.
That is acceptable.
Bad ordinary code can be read, debugged, profiled, tested, and improved. Today, much advanced type-level code is inaccessible to most TypeScript programmers before questions of code quality can even begin.
The desired cultural shift is from:
Look at this type wizardry.
to:
Here is the type-level program.
The proposal can be summarized in one sentence:
TypeScript's type system should be another evaluation domain of TypeScript itself.
The language already contains the computational power.
The opportunity is to peel away the magic.
A senior TypeScript developer's immediate reaction was that the examples "should just work." The proposed syntax appeared to require little explanation because it reused the value language's existing grammar and mental model.