How TypeScript Actually Works: Compilation, Type Erasure & Your First .ts File
Most TypeScript tutorials start with let name: string = "Alice" and move on, as if the colon and the word “string” are self-explanatory. They’re not — and skipping why that annotation exists is exactly why so many developers treat TypeScript as “JavaScript with extra homework” instead of the tool that actually catches their bugs before a user does.
This guide starts one layer deeper: what the TypeScript compiler actually does to your code, why none of it exists at runtime, and why a language that adds a compile step to JavaScript became the default choice for serious frontend and Node.js projects anyway.
The Problem TypeScript Was Built to Solve
JavaScript is dynamically typed: a variable can hold a number, then a string, then an object, and the language never complains — until something downstream tries to call .toUpperCase() on a number and the whole request crashes in production at 2 a.m.
function calculateTotal(price, quantity) { return price * quantity;}
calculateTotal(10, "5"); // "50" — string, not 500. No error, no warning.calculateTotal(10); // NaN — quantity was never checkedNeither of those calls throws. JavaScript coerces types instead of rejecting them, which is convenient for quick scripts and genuinely dangerous in a codebase with hundreds of files and dozens of contributors. Microsoft built TypeScript in 2012, led by Anders Hejlsberg (also the architect of C# and Delphi), specifically to bring static analysis to JavaScript at scale — Microsoft’s own internal teams were struggling to maintain huge JavaScript codebases (Office Online, Bing) without the safety net that C# and Java developers took for granted.
TypeScript’s core idea: you write JavaScript with type annotations, a compiler checks those annotations for you, and then it deletes them. That last part — deletion — is the piece most newcomers never quite internalize, and it explains almost every “why does TypeScript behave this way” question you’ll hit later.
TypeScript Is a Superset, Not a New Language
Every valid JavaScript file is already valid TypeScript. You can rename app.js to app.ts right now and it will compile with zero changes — TypeScript adds a type layer on top of JavaScript rather than replacing its syntax or runtime semantics.
This is a deliberate design constraint, not an accident — it’s why TypeScript adoption in an existing JavaScript codebase can be gradual, file by file, instead of a rewrite. Compare this to a language like Elm or ReasonML, which compile to JavaScript but aren’t supersets of it; migrating to those means rewriting, not annotating.
From .ts File to Running Code
Here’s the actual pipeline, and it looks deceptively similar to a compiled language like Java — right up until the last step:
app.ts ──tsc──▶ type checking ──▶ app.js (types stripped) ──▶ Node.js / browser (source, (compile-time (plain JavaScript, (runs it — no with types) only, no zero knowledge of knowledge that runtime cost) TypeScript ever existed) TS was involved)- You write
app.tswith type annotations. tsc(the TypeScript compiler) type-checks the file against every rule the type system can enforce — mismatched types, missing properties, wrong argument counts.tscemitsapp.js— ordinary JavaScript, with every type annotation, interface, and generic parameter completely removed. Nothing about types survives into the output file.- The JavaScript runtime executes
app.js, having no idea TypeScript was ever involved. Node.js and browsers do not run TypeScript — they only ever run the JavaScript that TypeScript compiled down to.
function greet(name: string): string { return `Hello, ${name}`;}// output.js — after tsc compiles itfunction greet(name) { return `Hello, ${name}`;}The : string annotations are simply gone. This is type erasure, and it’s the single most important fact about how TypeScript works: types exist purely to help the compiler (and your editor) catch mistakes before the code runs. They impose zero runtime cost, because by the time your code runs, they don’t exist anymore.
Why Type Erasure Explains So Much
Once you internalize that types disappear at compile time, several TypeScript behaviors that look strange suddenly make sense.
You cannot check a type at runtime.
function process(value: string | number) { if (value instanceof string) { ... } // ERROR — string isn't a runtime value, it's a compile-time concept}string is a type, not a class or a constructor — there’s nothing left at runtime to check instanceof against. This is exactly why TypeScript ships type narrowing (covered in its own guide) — typeof value === "string" works because typeof is a genuine JavaScript runtime operator, not a TypeScript-only construct.
Interfaces are completely invisible at runtime.
interface User { name: string; age: number;}// There is NOTHING in the compiled .js file corresponding to "User" — it never existed at runtime.This is why you can’t do if (obj instanceof User) for an interface — interfaces are 100% a compile-time construct with no runtime representation at all, unlike classes, which do generate real JavaScript.
Types can be wrong at runtime if the data lies to them.
function processApiResponse(data: { id: number; name: string }) { console.log(data.name.toUpperCase());}
const response = JSON.parse(rawJsonString); // response: any — TypeScript trusts you completely hereprocessApiResponse(response); // compiles fine, can still crash if the JSON shape is wrongTypeScript’s type system is a compile-time contract based on what you tell it, not a runtime guarantee. If your annotation says a value is a { id: number; name: string } but the actual data at runtime is null (an API returned an error payload, for instance), TypeScript has no way to catch that — it trusted the annotation. This is the single biggest source of “but TypeScript said this was safe!” bugs, and it’s why validating data at system boundaries (API responses, form inputs, file reads) with a runtime library like Zod remains necessary even in a fully-typed codebase.
Structural Typing: TypeScript’s Most Distinctive Design Choice
If you’re coming from Java or C#, TypeScript’s type compatibility rules will surprise you. Those languages use nominal typing — two types are compatible only if one explicitly declares it implements or extends the other. TypeScript uses structural typing — two types are compatible if they have the same shape, regardless of how they were declared.
interface Point2D { x: number; y: number;}
function logPoint(point: Point2D) { console.log(`(${point.x}, ${point.y})`);}
const point3D = { x: 10, y: 20, z: 30 };logPoint(point3D); // Works! point3D has at least x and y — extra properties are finepoint3D was never declared as a Point2D. It doesn’t matter — TypeScript checks whether the shape is compatible (does it have the required properties, with compatible types?), not whether there’s a declared inheritance relationship. This is often called “duck typing, but checked at compile time” — if it has the properties a Point2D needs, TypeScript treats it as one.
This has a real consequence worth knowing early: passing an object with extra properties to a function works fine (as above), but passing an object literal directly with extra properties gets rejected by a stricter check called “excess property checking”:
logPoint({ x: 10, y: 20, z: 30 });// Error: Object literal may only specify known properties, and 'z' does not exist in type 'Point2D'The difference: TypeScript applies extra scrutiny to object literals specifically, on the theory that a literal with a typo’d property name ({ xx: 10, y: 20 }) is more likely a mistake than a deliberate excess property — a variable assigned earlier doesn’t get this stricter check, because it might legitimately be a wider type being passed to a narrower parameter.
Setting Up Your First TypeScript File
npm install -g typescript # or add it as a dev dependency in a project: npm install --save-dev typescripttsc --versionfunction add(a: number, b: number): number { return a + b;}
console.log(add(2, 3));console.log(add(2, "3")); // compile-time error, caught before you ever run the filetsc hello.ts # produces hello.jsnode hello.js # runs the compiled JavaScriptTry it: change add(2, "3") and run tsc hello.ts. You’ll see something like:
hello.ts:7:14 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.That error happened before node ever ran anything. This is the entire value proposition in one example: a class of bug that would have silently produced "23" instead of 5 in plain JavaScript never made it to runtime at all.
For quick experimentation without a manual compile step, ts-node (or the newer, faster tsx) runs .ts files directly, compiling in memory on the fly — useful for scripts and REPL-style exploration, though production builds still compile to plain .js ahead of time rather than paying that compilation cost on every run.
What TypeScript Is Not
It’s not a stricter runtime. Nothing about TypeScript makes your JavaScript run faster, slower, or differently — the compiled output is ordinary JavaScript, executed by the exact same V8/SpiderMonkey/JavaScriptCore engine as before.
It’s not a guarantee against all bugs. It eliminates an entire category of bugs — type mismatches, missing properties, wrong argument counts — but says nothing about logic errors, race conditions, or off-by-one mistakes. A function typed correctly can still compute the wrong answer.
It’s not required to be “all or nothing.” TypeScript’s strict compiler flag (bundling several stricter checks) can be enabled gradually, and even without it, an untyped variable simply falls back to the any type — TypeScript will let you write loosely-typed code indefinitely if you choose to, which is exactly the “gradual adoption” story that made migrating legacy JavaScript codebases realistic instead of theoretical.
Frequently Asked Questions
Does TypeScript make my code faster? No. Compiled output is plain JavaScript, executed at the same speed as if you’d written it directly. Any performance difference you might notice comes from the discipline TypeScript encourages (catching accidental type coercion, dead code, etc.), not from the type system itself.
Do I need to compile TypeScript manually every time? In practice, no — bundlers (Vite, webpack, esbuild) and frameworks (Next.js, Angular CLI, NestJS) handle compilation as part of their build pipeline. Understanding tsc directly matters for debugging build issues and for standalone scripts, but day-to-day you’ll rarely invoke it by hand.
If types disappear at compile time, why do I see type errors in VS Code before I even save? Editors run a background TypeScript language service that performs the same type-checking tsc does, continuously, without emitting any output file — that’s the “squiggly red line” experience. It’s the same type checker, just running in “check only, don’t emit” mode in real time.
Can I use TypeScript without ever running tsc to check types, just to strip types for speed? Yes — tools like esbuild and swc strip TypeScript syntax without type-checking it at all (much faster, since checking is the expensive part), on the assumption that your editor or a separate tsc --noEmit step already caught type errors. This split (“fast transpile, separate slow type-check”) is increasingly common in modern build pipelines.
Is learning TypeScript worth it if I already know JavaScript well? Yes, and it’s a fast transition specifically because it’s a superset — you’re not learning a new language, you’re learning an annotation system and a set of rules for reasoning about shapes of data, layered on syntax you already write daily.
What’s Next
You now know the core mental model most tutorials skip: TypeScript type-checks your code against annotations you write, then deletes every trace of those annotations before your code ever runs. Structural typing, type erasure, and the compile step aren’t just trivia — they explain why narrowing works the way it does, why interfaces can’t be checked with instanceof, and why a well-typed function can still crash on bad runtime data.
Next: Setting Up a TypeScript Project, where you’ll configure tsconfig.json, enable strict mode, and wire up a real build — not just a single-file tsc invocation.