Inside the TypeScript Compiler: Parse, Bind, Check, Emit and How Inference Reasons

Key takeaways

Understanding how the TypeScript compiler parses, binds, type-checks, and emits code—and how structural typing and inference interact—makes both API design and debugging faster. This guide ties those internals to TypeScript 5 features and patterns that hold up in production.

This article explains how the TypeScript compiler processes a program, and how assignability and inference are decided in practice. It then connects those ideas to TypeScript 5 features (standard decorators, satisfies, const type parameters) and to patterns that hold up in larger codebases. For a feature-by-feature tour of TypeScript 5 releases, see the TypeScript 5 guide.


Compiler pipeline: parse → bind → check → emit

Running tsc, or opening a file in an editor that uses the TypeScript language service, runs the same compiler over your program. The work is split into four stages, and knowing which stage produces a given error makes compiler issues and performance discussions easier to follow.

Parse

The scanner and parser turn source text into an abstract syntax tree (AST). There are no types yet, only syntax: an annotation such as x: number is a type node attached to the declaration.

Bind

The binder walks the AST, creates a symbol for each declaration, and records scopes, so that each identifier can later be resolved to the declaration it refers to. It also tracks whether a symbol has a value meaning, a type meaning, or both, which is how User can be a class (value and type) in one file and an interface (type only) in another. Some duplicate-declaration errors come from this stage. The binder also builds the control-flow graph that narrowing later uses.

Check

The type checker computes types for expressions and verifies assignability, evaluates conditional and mapped types, infers generic type arguments, and applies narrowing. It works lazily, computing a type when something asks for it, and on large projects it is where most of the CPU time goes.

Emit

The emitter runs transformers that remove type annotations and lower syntax to the configured target, then prints JavaScript and, if requested, .d.ts files and source maps. TypeScript-only constructs with runtime behaviour, such as enum, namespace, and import x = require(), are transformed here. With noEmit: true this stage is skipped, which is the usual setup when a bundler produces the JavaScript and tsc only checks types.

flowchart LR
  S[Source text] --> P[Parse — AST]
  P --> B[Bind — symbols & scopes]
  B --> C[Check — types & errors]
  C --> E[Emit — JavaScript]

Type system model

Structure and type operators

Object, function, and class instance types are compared by structure: which members they have and what types those members have. Unions, intersections, conditional types, and mapped types are operators that build new types from existing ones. When a type becomes slow to check or produces unreadable errors, the cause is usually one of these operators applied at scale: a union with thousands of members, a deeply recursive conditional type, or a mapped type over a very large key set.

Assignability

Whether an expression of type A may appear where T is expected is decided by assignability rules. With strictNullChecks, null and undefined are no longer assignable to other types. With strictFunctionTypes, parameters of function types are checked contravariantly: a (x: string | number) => void can be used where (x: string) => void is expected, but not the other way round. The check applies to properties declared with function-type syntax; parameters of methods declared with method syntax (m(x: string): void) stay bivariant for compatibility, which is a known hole in soundness.

Control-flow narrowing

if, switch, return, throw, and assignments narrow the type of a variable along each path through the code. The checker computes the narrowed type of each reference by walking the control-flow graph backwards from that reference, so the same variable can have different types on different lines. Narrowing is reset by assignments, and narrowing of a property or a reassigned let variable generally does not carry into a callback, because the checker cannot know when the callback runs (TypeScript 5.4 relaxed this for variables that are not reassigned after the closure is created).


Structural typing and its nominal exceptions

Structural typing

If a value has compatible members, assignment is allowed even when the type names differ. { id: number; name: string } is assignable to { id: number }. The exception is a fresh object literal written directly at the assignment site, which gets an excess property check (see below).

Nominal-like exceptions

Class members declared private or protected make a class behave nominally: two classes with identical shapes are not compatible if those members come from different declarations. This prevents a structurally matching object from being passed where code expects to access another class’s private state. Enums are another case: two different enum types are not assignable to each other even when their values coincide, and a string enum member is not assignable from a plain string literal.

Branding

When two primitives must not be confused, such as UserId and OrderId that are both strings, structural typing cannot tell them apart. A brand adds a property that exists only in the type system:

declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: true };

function toUserId(s: string): UserId {
  // validate here, then assert
  return s as UserId;
}

function loadUser(id: UserId) {
  // ...
}

loadUser(toUserId("abc"));   // OK
// loadUser("abc");          // error: string is not assignable to UserId

The brand has no runtime cost, since UserId values are ordinary strings. The only way to create one is through an assertion, so put that assertion in a single validating function.


How inference reasons

Bidirectional typing

In contextually typed positions, such as an argument to a typed callback parameter or the initializer of an annotated variable, type information flows from the context into the expression: arr.map(x => x.id) knows the type of x from arr. Elsewhere, the type flows out of the expression. Many “why is this literal preserved here but widened there” questions come down to whether the position had a contextual type.

Widening and best common type

A literal in a mutable position is widened: let x = "a" has type string, while const x = "a" keeps "a". For array literals, the element type is the best common type of the elements; [1, 2] is number[], and [1, "a"] becomes (string | number)[] because no single element type is a supertype of the others. as const, satisfies, and const type parameters exist to keep literal types when the default widening loses information you need.

Generic inference

For a call f(arg) where T is not given explicitly, T is inferred from the arguments. Each argument may contribute a candidate; when candidates differ, the checker tries to find one that the others are assignable to and reports an error if it cannot. A const type parameter (TypeScript 5.0) asks for the narrowest literal and readonly tuple types, as if the argument had been written with as const:

function tuple<const T extends readonly unknown[]>(items: T): T {
  return items;
}

const t = tuple([1, 2, 3]);
// t: readonly [1, 2, 3]   (without `const`, T would be number[])

This is useful for route tables, event name lists, and discriminated unions built from data.

Excess property checks

When an object literal is written directly where a type is expected, TypeScript reports properties the target type does not declare, even though the value would be structurally assignable. The idea is that an unknown property in a literal is almost always a typo. The check only applies to fresh literals: assign the same object to a variable first and pass the variable, and the check disappears. When one call site errors and an apparently identical one does not, check whether the literal is passed directly or through a variable.

Several inference sources

For a generic call, candidates come from arguments, from the contextual type of the call, and from defaults and constraints. Callback parameters are inferred in a later pass than plain arguments, so placing a callback after the arguments that determine T often fixes inference that “almost works”. When candidates disagree, the result can be wider than you wanted; explicit type arguments, const type parameters, or satisfies let you say which shape you mean.


TypeScript 5 features in practice

Standard decorators

TypeScript 5.0 implements the ECMAScript decorators proposal. A standard decorator receives the decorated value and a context object, instead of the legacy (target, key, descriptor) arguments used with experimentalDecorators.

function logged<T extends new (...args: any[]) => object>(
  value: T,
  context: ClassDecoratorContext<T>,
) {
  const name = String(context.name);
  return class extends value {
    constructor(...args: any[]) {
      super(...args);
      console.log(`[${name}] constructed`);
    }
  };
}

@logged
class Service {}

The constraint on T is what makes class extends value legal: a parameter typed Function is not a constructor type, and TypeScript requires the mixin constructor to take a single ...args: any[] rest parameter. Frameworks built on legacy decorators, including NestJS and TypeORM, still require experimentalDecorators (and often emitDecoratorMetadata), and the two decorator systems have different semantics, so follow the framework’s guidance rather than switching the flag.

satisfies

satisfies (TypeScript 4.9) checks that a value conforms to a type without changing the value’s inferred type.

type Color = "red" | "green" | [number, number, number];

const palette = {
  primary: "red",
  secondary: [0, 255, 0],
} satisfies Record<string, Color>;

palette.primary;    // type "red"
palette.secondary;  // type [number, number, number]

With a type annotation (const palette: Record<string, Color>), both properties would have type Color, and any key would be allowed. With as Record<string, Color>, a wrong value would be silently accepted, because an assertion only requires the types to be comparable. satisfies gives the check of an annotation and the precision of inference.

moduleResolution: "bundler"

TypeScript 5.0 added "moduleResolution": "bundler" for projects where a bundler such as Vite or esbuild resolves imports. It supports package.json exports and imports like the Node.js ESM mode, but does not require file extensions in relative imports:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "noEmit": true
  }
}

Code that tsc emits for direct execution in Node.js should use "module": "nodenext" instead, because Node.js does require extensions.


Patterns for larger codebases

Strictness

Enable strict, and consider noUncheckedIndexedAccess, which makes arr[i] and record[key] include undefined, and exactOptionalPropertyTypes, which distinguishes a missing property from one set to undefined. In an existing codebase, tighten types at the boundaries first: HTTP responses, environment configuration, and database rows are where wrong assumptions enter.

Runtime validation at the edges

Types are erased at runtime, so data from the network or disk is whatever it is, regardless of the annotation. Validate it with a schema library such as Zod or Valibot and derive the static type from the schema (z.infer<typeof schema>), so the check and the type cannot drift apart.

Explicit error paths with Result

A function that throws does not say so in its type. Returning a discriminated union makes failure part of the signature:

type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;

function parseId(s: string): Result<number, "not-a-number"> {
  const n = Number(s);
  return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, error: "not-a-number" };
}

The compiler forces callers to check ok before reading value, but it cannot force them to look at the result at all, so a lint rule against ignored return values helps.

Small public surface

Use the exports field in package.json to publish only the entry points you intend to support. Anything importable becomes something consumers depend on, and with structural typing they can also depend on the exact shape of internal types that happen to leak through.

Operations

Pin one TypeScript version per repository through package.json and the lockfile, since new versions regularly add checks that turn previously accepted code into errors, and run tsc --noEmit in CI when a bundler does the actual build. In monorepos, project references with tsc --build let unchanged packages be skipped. skipLibCheck speeds up checking by not type-checking .d.ts files, at the cost of missing conflicts between declaration files.