TypeScript Complete Guide — Type System, Inference, Compiler Pipeline, TS 5, Production Patterns
이 글의 핵심
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.
What This Guide Covers
This article explains how the TypeScript language service and compiler cooperate on the same program, and how assignability and inference are decided in practice. It then connects those ideas to TypeScript 5 (standard decorators, satisfies, const type parameters) and patterns that scale in real codebases.
Note: There is a focused TypeScript 5 feature roundup (
typescript-5-complete-guide-en). This post is a broader integration centered on the type system, the compilation pipeline, and production usage.
1. Compiler pipeline: parse → bind → check → emit
Running tsc (or loading the editor language service) is not a single “error pass.” It is a pipeline that repeatedly transforms and analyzes source text. The four stages below are the mental model most engineers use when reading compiler discussions.
1.1 Parse
The lexer and parser turn source text into an abstract syntax tree (AST). At this stage there are no types—only syntax. Type annotations such as x: number are represented as syntax attached to the tree.
1.2 Bind (symbols and scopes)
The binder walks the AST, connects declarations to symbols, and builds scopes. Each identifier resolves to a symbol; shadowing and duplicate declarations are diagnosed here. The skeleton for “is this User a type or a value?” is prepared before types are computed.
1.3 Check (type checking)
The type checker assigns types to expressions and verifies assignability, subtype relationships, variance for function types, conditional and mapped type evaluation, generic inference, and much more. On large projects, most CPU time is usually spent here—not in parsing.
1.4 Emit
When emitting JavaScript, transformers and the printer lower TypeScript-only syntax to the configured target. Features like enum, namespace, and legacy import = are transformed in this phase. With noEmit: true, emit is skipped or minimized while checking still runs.
flowchart LR S[Source text] --> P[Parse → AST] P --> B[Bind → symbols & scopes] B --> C[Check → types & errors] C --> E[Emit → JavaScript]
2. Type system architecture (conceptual model)
2.1 Structure vs. combinators
Object, function, and class instance types are mostly compared by structure—the presence and types of members. Unions, intersections, conditional types, and mapped types act as combinators that build a lattice of types. When types “feel too complex,” look for union explosion, deep conditional recursion, or mapped types applied to overly large keys.
2.2 Assignability and subtyping
Whether expression a may appear where type T is expected is decided by assignability rules. With strictNullChecks, null and undefined are no longer silently assignable to most types. With strictFunctionTypes, parameter positions are checked contravariantly for function types, which can surface surprising errors in callback-heavy code—those errors usually point at real unsoundness.
2.3 Control-flow narrowing
Statements like if, switch, return, and throw narrow the set of possible types for a variable. The checker may revisit a function body multiple times (within limits) to refine types per control-flow branch, so the same symbol can have different apparent types in different blocks.
3. Structural typing vs. nominal flavor
3.1 Structural typing
If two types have compatible members, assignment is allowed—even if the type names differ. For example, { id: number; name: string } is usually assignable where { id: number } is required (with separate rules for excess property checks on object literals).
3.2 Nominal-like exceptions
- private / protected class members: two classes with distinct declarations are not compatible merely because their public shapes match.
- Enums generate runtime objects; mixing unrelated enums is intentionally awkward. String enums often interoperate more smoothly with string-literal unions.
3.3 Branding (phantom types)
When two primitive values must not be confused (UserId vs OrderId, both string), structural typing is too loose. A brand using unique symbol or an intersection creates a nominal-like distinction at compile time.
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: typeof UserIdBrand };
function toUserId(s: string): UserId {
return s as UserId;
}
function loadUser(id: UserId) {
// ...
}
// loadUser("abc"); // can be made to reject plain strings at the call site
4. Type inference (how to reason about it)
4.1 Bidirectional typing
In contextually typed positions (annotated variables, parameters of typed callbacks), types flow inward from context. In unconstrained positions (const x = []), inference flows outward from the initializer. “Why is this literal preserved here but widened there?” usually comes down to this split.
4.2 Best common type
When several expressions must collapse to one type (e.g., array literals), TypeScript picks a best common type. If one branch widens to number, the whole array may widen. as const, satisfies, and const type parameters exist partly to pin intent when the default common type is too wide.
4.3 Generic inference
For a call f(arg) with T unspecified, T is inferred from arg. Multiple arguments constrain T jointly; the solver prefers safe, general solutions. const T biases inference toward narrow literal and tuple types—useful for route tables, event names, and discriminated unions built from data.
function tuple<const T extends readonly unknown[]>(items: T): T {
return items;
}
const t = tuple([1, 2, 3] as const);
// readonly [1, 2, 3]-like inference
4.4 Excess property checks
When you pass an object literal directly to a variable or parameter position, TypeScript may error on unknown properties even if the value would be structurally assignable in other contexts. This fresh object literal rule reins in the “anything with enough fields goes” feeling of pure structural typing. If you assign the literal to an intermediate variable first, the same object can behave differently—if “why does only this site error?” appears, check whether the literal is fresh or widened and whether an assertion was used.
4.5 Inference sources and priority (overview)
For a generic call, the compiler combines inference sources: arguments, contextual types, defaults, and constraints. When candidates disagree, TypeScript may pick a wider type or report an error. That is why callbacks sometimes show narrow parameters but wide returns—tune with const type parameters, explicit type arguments, or satisfies to bias inference toward the shape you need.
5. TypeScript 5 highlights (practical)
5.1 Standard decorators
TypeScript 5 aligns with the ECMAScript decorators proposal: decorators receive a context object instead of the legacy target/descriptor triple. Libraries built on experimentalDecorators (many NestJS/ORM setups) require a migration story—do not flip flags blindly in production.
function logged(value: Function, context: ClassDecoratorContext) {
const name = String(context.name);
return class extends value {
constructor(...args: unknown[]) {
super(...args);
console.log(`[${name}] constructed`);
}
};
}
@logged
class Service {
constructor() {}
}
5.2 satisfies
satisfies checks conformance to a type without erasing the inferred type of the value. It shines for config objects and maps where you want validation and precise literal types for keys and values.
type Color = "red" | "green" | [number, number, number];
const palette = {
primary: "red",
secondary: [0, 255, 0],
} satisfies Record<string, Color>;
palette.primary.toUpperCase(); // still the literal "red"
5.3 Performance and moduleResolution: "bundler"
TypeScript 5 improved incremental builds and resolution. For Vite/esbuild-style workflows, "moduleResolution": "bundler" matches how bundlers resolve modules:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
}
}
6. Production TypeScript patterns
6.1 Strictness as a team decision
Enable strict and—where affordable—noUncheckedIndexedAccess and exactOptionalPropertyTypes. In brownfield codebases, tighten boundaries first: HTTP responses, environment config, and database rows.
6.2 Runtime validation at the edges
Types disappear at runtime. At IO boundaries, pair TypeScript with Zod, Valibot, io-ts, or similar, and derive types with z.infer<typeof schema> so schemas and types stay one source of truth.
6.3 Explicit error paths with Result
Exceptions are easy to miss in large call graphs. A Result<T, E> style return forces callers to consider failure—types alone cannot enforce handling, so combine with lint rules or conventions.
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" };
}
6.4 Minimize the public surface
Use package.json exports and explicit entry types so consumers see a small, stable API. That reduces accidental coupling through over-broad structural matches across packages.
7. Migration and operations checklist
- Pin one TypeScript version per repo (
package.json+ lockfile) and runtsc --noEmitin CI. - If a framework still requires
experimentalDecorators, follow its upgrade guide before adopting standard decorators everywhere. - For monorepos, consider project references, and decide
skipLibCheckbased on safety vs. speed trade-offs.
8. Summary
- The pipeline is parse → bind → check → emit; check usually dominates on large projects.
- The type system is mostly structural, but private/protected, enums, and brands add nominal-like guardrails.
- Inference is the interaction of context, best common type, and generic inference—use
satisfies,as const, andconsttype parameters to express intent. - In production, validate at boundaries, model errors explicitly, and shrink public APIs—types alone cannot cover runtime or organizational constraints.
Related reading
- TypeScript 5 feature-focused guide
- Type narrowing guide
- Next.js 15 guide — framework integration
FAQ
Q. Should I use as or satisfies?
A. as is an assertion that can silence errors; satisfies checks and preserves inference. For config objects and maps, satisfies is usually safer.
Q. Type checking is slow—what do I optimize first?
A. Look for huge unions, deep conditional types, pathological recursion, and overly wide include globs. Combine with skipLibCheck, project references, and incremental as appropriate.
Q. How do I stop structural typing from hiding bugs?
A. Use branded types, small wrapper types, narrow public exports, and runtime validation at boundaries. Types are powerful but not a substitute for domain invariants at runtime.