Skip to content

Instantly share code, notes, and snippets.

@Ustice
Created August 3, 2026 03:38
Show Gist options
  • Select an option

  • Save Ustice/2b01fa8381afbccf98614e4c994bbec4 to your computer and use it in GitHub Desktop.

Select an option

Save Ustice/2b01fa8381afbccf98614e4c994bbec4 to your computer and use it in GitHub Desktop.
Type Functions: Making Type-Level Programming Ordinary

Type Functions: Making Type-Level Programming Ordinary

Status: Brainstorm / discussion draft
Goal: Explore a TypeScript-compatible way to make type-level programming look and behave more like ordinary TypeScript.

Summary

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.


Motivation

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.


Core idea: another evaluation domain

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:

  • function evaluates runtime values;
  • type function evaluates 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.


Type functions

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

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.


is: assignability and narrowing

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 === B

meaning exact identity of type-level values, should such an operation be useful and well-defined.


Local variables

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.


Loops instead of recursive encodings

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.


Conditionals without conditional-type syntax

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.


Pattern matching

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.


Debugging

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.


Performance and compiler implementation

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:

  1. Parse type functions as ordinary TypeScript-like syntax.
  2. Lower them into a restricted, pure type-evaluation IR.
  3. Evaluate symbolic type values rather than JavaScript runtime values.
  4. Enforce resource budgets and deterministic evaluation.
  5. Cache calls based on function identity and type-level arguments.
  6. 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.


Purity and side effects

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.


Compatibility

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.


Comparison

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.


Examples

Awaited

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;
}

Exclude

type function Exclude(T: type, U: type) {
  return T.filterUnion((member) => !(member is U));
}

Pick

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;
}

Higher-order type function

type function Map<T, U>(
  values: readonly T[],
  transform: type (value: T) => U,
) {
  return values.map(transform);
}

Type constructor as a value

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

What this is not

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?


Open questions

This draft deliberately leaves several questions unresolved.

What exactly is a type-level value?

Possible candidates include:

  • ordinary types;
  • unions;
  • tuple structures;
  • keys;
  • literal types;
  • type constructors;
  • constraints;
  • possibly kinds represented as function signatures.

How should open arrays differ from finite tuples?

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.

How should union operations work?

Current conditional types distribute implicitly in specific syntactic circumstances.

A type-function model might make union mapping, filtering, and folding explicit.

What does exact equality mean?

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.

How are generic type aliases and type functions related?

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.

What are the termination and resource rules?

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.

How much of JavaScript should be available?

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.


Why this may matter

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.


Design principle

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.

@Ustice

Ustice commented Aug 3, 2026

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment