How TypeScript Works
Inside the compiler — from source code to type-checked JavaScript, and how your editor uses the language service.
TypeScript is often described as "JavaScript with types." But what does that actually mean? What happens when you write let x: number = 42; and hit save? How does the compiler know that x = "hello" is an error? And how does your editor instantly underline mistakes and suggest fixes before you even run the code?
This page takes you inside TypeScript's architecture — from the relationship between TypeScript and JavaScript, through the compiler's internal pipeline, to the language service that powers VS Code, WebStorm, and every other modern editor.
The JavaScript ↔ TypeScript relationship
Here's the most important fact about TypeScript: TypeScript is a syntactic superset of JavaScript. That means every valid JavaScript program is a valid TypeScript program. If you take a .js file and rename it to .ts, it compiles. You don't have to change a single character.
TypeScript adds optional annotations on top of JavaScript. These annotations describe the types of values — whether a variable holds a number, a string, an object with specific properties, or a function with a particular signature. The TypeScript compiler checks these annotations against your code, reports errors, and then erases the types to produce plain JavaScript.
Let's see this in action:
The code above shows the key mechanic: TypeScript checks that y is used consistently as a number. If you try to assign a string to y, the compiler stops you. But the runtime code — the JavaScript that actually executes — has no mention of : number. Types exist only at compile time.
Compile time vs. runtime
This distinction is crucial: TypeScript's types only exist during compilation. At runtime, TypeScript code is just JavaScript.
When you write:
function greet(name: string): string {
return `Hello, ${name}!`;
}The compiled JavaScript is:
function greet(name) {
return `Hello, ${name}!`;
}The : string annotations vanish. There's no runtime type-checking of the name parameter. If you call greet(42) from JavaScript (or from untyped legacy code), the function will happily return "Hello, 42!". TypeScript can't protect you from code it doesn't type-check.
This is by design. Type erasure keeps TypeScript's output clean and fast. It also means TypeScript can compile to older JavaScript versions (ES5, ES3) while using modern syntax (classes, async/await). The tradeoff is that TypeScript assumes you've type-checked your whole program — it doesn't add runtime guards.
TypeScript is not a runtime guard
If you're writing a public API or receiving data from an external source (like a REST endpoint), TypeScript alone won't validate it. You'll need runtime validation libraries (like Zod, io-ts, or Yup) to check incoming data at runtime. TypeScript's types are a compile-time contract, not a runtime fence.
The structural type system (a first look)
One of TypeScript's defining features is its structural type system (also known as duck typing). In languages like Java or C#, two types are compatible only if one explicitly inherits from the other. In TypeScript, two types are compatible if they have the same shape — the same properties and methods.
TypeScript doesn't care about class hierarchies or explicit implements declarations (though you can use them). It cares whether the object has the right properties. This makes TypeScript feel like JavaScript — where you often pass around plain objects — rather than forcing OOP patterns.
We'll explore structural typing in depth later. For now, just know: TypeScript checks structure, not names.
The compiler pipeline
When you run tsc (the TypeScript compiler) or save a file in VS Code, your code flows through a multi-stage pipeline. Each stage transforms or analyzes the code, building up information until the type checker can prove your program is safe (or report errors).
Here's the high-level flow:
Let's walk through each stage:
1. Scanner (Lexer)
The scanner reads your source code character by character and breaks it into tokens — the smallest meaningful units of syntax. For example, the line:
let x: number = 42;becomes:
LET_KEYWORD IDENTIFIER("x") COLON IDENTIFIER("number") EQUALS NUMERIC_LITERAL(42) SEMICOLONThe scanner doesn't understand meaning yet. It just recognizes keywords (let, function, if), identifiers (x, myFunction), operators (+, =), and literals (42, "hello").
2. Parser
The parser takes the stream of tokens and builds an Abstract Syntax Tree (AST) — a tree structure representing the program's syntactic structure. For the line above, the AST node might look like:
VariableStatement
├─ VariableDeclarationList
│ └─ VariableDeclaration
│ ├─ Identifier: "x"
│ ├─ TypeAnnotation: NumberKeyword
│ └─ Initializer: NumericLiteral(42)The AST captures the structure of the code: "This is a variable declaration named x, with type number, initialized to 42." The parser checks syntax (no missing semicolons or braces), but it doesn't check types yet.
3. Binder
The binder walks the AST and builds a symbol table — a map from names (x, myFunction) to their declarations. This is where TypeScript figures out scope. If you reference x inside a function, the binder determines which x you mean (the global one? a local variable? a parameter?).
The binder also links import and export statements across files, building a module graph. By the end of the binding phase, TypeScript knows what every identifier refers to.
4. Type Checker
The type checker is the heart of TypeScript. It walks the AST again (now enriched with symbol information) and assigns a type to every expression:
42has typenumber"hello"has typestringx + yhas typenumber(if bothxandyare numbers)function greet(name: string): string { ... }has type(name: string) => string
The type checker verifies that:
- Variables are used consistently with their declared types
- Functions are called with the right number and types of arguments
- Properties accessed on objects actually exist
- Control flow makes sense (e.g., you don't use a variable before it's defined)
If the type checker finds a violation, it reports an error. But it doesn't stop — it keeps checking the rest of the program so you can see all errors at once.
5. Emitter
If type checking succeeds (or if you've allowed errors with --noEmitOnError false), the emitter generates JavaScript. It walks the AST one more time, translating TypeScript syntax (like let x: number = 42) into equivalent JavaScript (let x = 42).
The emitter also:
- Downlevels modern syntax (like
async/awaitor classes) to older JavaScript (like ES5 generators or prototype chains) based on yourtargetsetting. - Generates declaration files (
.d.ts) that describe the types of your exports, so other TypeScript projects can import and type-check against your code. - Applies transformations (like JSX →
React.createElementor decorators → metadata).
The output is clean, readable JavaScript — often indistinguishable from what a human would write.
Control flow analysis
One of TypeScript's most powerful features is control flow analysis — the compiler's ability to track how types change within branches, loops, and conditionals.
The type checker doesn't just look at the declared type string | number. It tracks the typeof check and narrows the type in each branch. This is called type narrowing or flow-sensitive typing, and it eliminates many casts and assertions you'd need in other languages.
We'll cover narrowing deeply in later lessons. For now, recognize that TypeScript is doing sophisticated reasoning about your program's logic, not just checking syntax.
The language service: TypeScript in your editor
When you open a .ts file in VS Code or WebStorm, you get instant feedback: red squiggles under errors, autocomplete as you type, tooltips showing function signatures, refactorings like "Rename Symbol" that work across files. How?
The TypeScript compiler exposes a language service — an API that editors can call to ask questions like:
- "What's the type of this variable?"
- "What properties does this object have?" (for autocomplete)
- "If I rename this function, what files need to change?"
- "What are all the references to this symbol?"
The language service uses the same type checker that tsc uses. When you save a file, the editor sends the updated content to the language service, which re-runs the binder and checker on the changed file (and any files that import it), then reports errors and updated type information back to the editor.
This tight integration between the compiler and the editor is why TypeScript feels so responsive. You don't have to run a build command to see errors. You don't have to guess what properties an object has. The compiler is always running in the background, keeping your editor in sync with the type system.
Incremental compilation and caching
Type-checking a large codebase (tens of thousands of files) would be slow if TypeScript re-checked everything from scratch every time. To stay fast, the compiler uses several strategies:
Incremental compilation: When you change one file, TypeScript only re-checks that file and the files that import it (transitively). Unchanged files are skipped.
Declaration files (.d.ts): When you import a third-party library (like React or Lodash), TypeScript doesn't re-check the library's source code. It reads the .d.ts declaration file — a compact summary of the library's types — which is much faster to parse.
tsc --watch mode: In watch mode, the compiler stays running, keeping an in-memory representation of your project. When you save a file, it incrementally updates its internal state rather than restarting from scratch. This makes type-checking feel near-instantaneous.
Project references (for monorepos): If you have multiple TypeScript projects in one repository, you can configure them as "project references." TypeScript builds each project once, emits .d.ts files, and then other projects import those declarations instead of re-checking the source.
These optimizations are why TypeScript scales to massive codebases (Microsoft's internal repos have hundreds of thousands of TypeScript files) while still providing instant feedback in editors.
What the compiler can't do
TypeScript's type checker is powerful, but it has limits:
It can't check runtime values. If you fetch JSON from an API, TypeScript has no idea what shape it has. You must validate it yourself (or use a library like Zod) and cast it to a type.
It can't prevent all logic errors. TypeScript catches type errors (passing a string where a number is expected), but it won't catch off-by-one errors, infinite loops, or wrong business logic.
It can't enforce exhaustiveness without help. If you add a new case to a union type, TypeScript won't automatically force you to handle it everywhere — unless you use specific patterns (like never checks) that we'll cover later.
It trusts your annotations. If you write const x: number = "hello" as any as number, TypeScript believes you. Type assertions (as) and any are escape hatches — use them sparingly, or you'll defeat the purpose of static types.
Despite these limits, TypeScript catches the majority of bugs that plague JavaScript codebases: typos, refactoring mistakes, mismatched function arguments, missing properties, and null/undefined errors.
Types as a design language
Beyond catching bugs, TypeScript changes how you think about code. Types become a design language — a way to communicate intent, document contracts, and encode constraints.
When you write:
function fetchUser(id: string): Promise<User | null> {
// ...
}You're saying:
- "This function takes a string ID."
- "It returns a Promise — it's asynchronous."
- "The result is either a
Userobject ornull(maybe the user doesn't exist)."
Future you (and your teammates) don't have to read the implementation or write a test to understand this contract. It's right there in the signature. And if you refactor fetchUser to return User | NotFoundError instead of User | null, TypeScript will force every caller to update their error handling.
Types scale your brain. They let you work on one module without holding the entire system in your head. They make codebases readable and navigable.
The journey from source to JavaScript
Let's summarize the full journey:
- You write TypeScript with type annotations.
- The scanner tokenizes your code.
- The parser builds an Abstract Syntax Tree.
- The binder resolves names and builds the symbol table.
- The type checker verifies types and reports errors.
- The emitter produces JavaScript (with types erased) and
.d.tsdeclaration files. - Your editor uses the language service (same type checker) to provide instant feedback, autocomplete, and refactoring.
At the end of this pipeline, you have two artifacts:
- JavaScript files (
.js) that run in any JavaScript engine (Node, browsers, Deno). - Declaration files (
.d.ts) that let other TypeScript projects import your code and get full type checking.
The types never reach production. They're a compile-time tool for correctness and productivity.
What happens to TypeScript's type annotations at runtime?
They are checked by the JavaScript engine for validation
They are converted into runtime type guards
They are erased during compilation and do not exist in the emitted JavaScript
They are kept as comments in the JavaScript output