TypeScript is JavaScript with a static type system layered on top. You annotate your code with types, a compiler checks them before the code runs, and then the types are erased to produce plain JavaScript that Node executes. The payoff is catching a whole class of bugs — typos, wrong argument shapes, undefined access, refactors that miss a call site — at your desk instead of in production, plus editor autocomplete and self-documenting function signatures. The vast majority of serious Node projects now use it. This page explains what TypeScript actually does, the compile step, the crucial fact that types vanish at runtime, type inference, and the realistic trade-offs.
What TypeScript adds
TS
// Annotate the shapes; the compiler enforces them:
interface User {
id: number
name: string
email?: string // optional
}
function greet(user: User): string {
return `Hello, ${user.name}`
}
greet({ id: 1, name: 'Ada' }) // ✅ ok
greet({ id: 2 }) // ❌ compile error: 'name' is missing
greet({ id: 3, name: 'Bo', age: 9 }) // ❌ compile error: 'age' is not in User
error TS2741: Property 'name' is missing in type '{ id: number; }'
but required in type 'User'.
You describe the shape of data and function signatures; the compiler rejects code that violates them
TypeScript lets you declare the **shape** of your data — object structures via `interface`/`type`, function parameter and return types, unions, generics — and then **statically checks** that every use conforms. Pass an object missing a required field, call a function with the wrong argument type, return the wrong shape, or access a property that doesn't exist, and you get a compile error pointing at the exact line, *before the code ever runs*. This turns a large category of runtime bugs into immediate editor feedback, and the same type information powers autocomplete, inline docs, and safe refactoring (rename a field and every affected site lights up). Crucially, TypeScript is a **superset** of JavaScript: all valid JS is valid TS, so you can adopt it gradually.
The compile step — TS → JS
Text
app.ts ──► tsc (the TypeScript compiler) ──► app.js ──► node app.js
(typed) type-check + strip types (plain JS) (Node runs JS)
Node CANNOT run .ts files directly* — it runs the compiled .js output.
*Modern runtimes (tsx, ts-node, and recent Node) can run TS by compiling
on the fly, but it's the same idea: TS is transpiled to JS to execute.
Node runs JavaScript, not TypeScript — `tsc` type-checks and transpiles `.ts` to `.js`, which is what actually runs
Node's engine executes **JavaScript**, not TypeScript, so there's always a translation step. The TypeScript compiler **`tsc`** does two jobs at once: it **type-checks** your code (reporting errors) and **transpiles** it to plain JavaScript (downleveling modern syntax to your target and stripping all the type annotations). You run the emitted `.js`. In development you usually skip the manual build with a tool that compiles on the fly — [`ts-node` or `tsx`](/nodejs/ts-node-tsx), and recent Node versions can even run `.ts` directly — but conceptually it's identical: TypeScript is *transpiled* to JavaScript before it runs. For production you typically `tsc` to a `dist/` folder and ship/run that compiled output. The [setup page](/nodejs/typescript-setup) covers `tsconfig.json` and the build.
Types are erased — they don't exist at runtime
TS
interface User { id: number; name: string }
function handle(input: unknown) {
// ❌ You CANNOT do this — 'User' doesn't exist at runtime:
// if (input instanceof User) ... // User is a type, not a value
// if (typeof input === 'User') ... // only 'object', 'string', etc. exist
// ✅ You must check the SHAPE at runtime yourself:
if (typeof input === 'object' && input !== null && 'id' in input) {
// ...narrowed, but TS can't verify external data for you
}
}
Types vanish after compilation — they can't validate data from the network, files, or user input at runtime
This is the most important thing to internalize: **TypeScript types are completely erased during compilation** and do not exist when your program runs. They are a *compile-time* contract, nothing more. The practical consequence: types **cannot validate runtime data**. When JSON arrives from an API, a request body, a database, or a file, TypeScript will happily *let you assert* it's a `User` (`data as User`), but nothing checks that it actually is — if the real data is wrong-shaped, you get a runtime crash despite "having types." For any data crossing your program's boundary (HTTP, files, env vars, message queues) you must do **real runtime validation** with a library like [Zod](/nodejs/zod) or [Joi](/nodejs/joi), which checks the actual value *and* gives you a correct TypeScript type. Types catch your *code's* mistakes; they do not police the outside world.
Type inference — you write fewer annotations than you'd think
TS
const count = 5 // inferred: number — no annotation needed
const names = ['a', 'b'] // inferred: string[]
// Return type inferred as { id: number; label: string }:
function makeTag(id: number) {
return { id, label: `#${id}` }
}
// Annotate at BOUNDARIES (params, public APIs); let inference handle the rest:
function totalPrice(items: { price: number }[]): number {
return items.reduce((sum, item) => sum + item.price, 0) // 'sum'/'item' inferred
}
TypeScript infers most types automatically — annotate function parameters and public APIs, let it infer the rest
A common misconception is that TypeScript means annotating everything. In practice the compiler **infers** most types: assign `const count = 5` and it knows `count` is a `number`; return an object literal and it infers the return shape; the callback params in `.map`/`.reduce` are inferred from the array. The idiomatic style is to annotate **boundaries** — function *parameters* (which can't be inferred from a call), public/exported API signatures, and places where you want to *enforce* a specific type rather than accept whatever's inferred — and let inference handle local variables and obvious returns. This keeps code clean (not littered with redundant `: number`) while still fully typed. Over-annotating is noise; under-annotating boundaries loses safety. Annotate where it documents intent or where inference can't reach.
The trade-offs — honestly
Benefit
Cost
Catches type bugs before runtime
A build/transpile step to set up & run
Editor autocomplete & inline docs
Learning curve (generics, narrowing, config)
Safe, confident refactoring
Wrestling third-party types / missing @types
Self-documenting signatures
Temptation to overuse any (defeats the point)
Scales to large teams/codebases
Slightly slower iteration for tiny scripts
`any` switches off type checking — overusing it (or sloppy `as` casts) gives you the costs of TS with none of the safety
TypeScript's value is real but not free. You take on a **build step**, a **learning curve** (generics, union narrowing, `tsconfig` options), and occasional friction with **third-party libraries** that lack type definitions (you install `@types/x` or write a declaration). The benefits — fewer runtime bugs, fearless refactoring, great tooling — clearly win for anything beyond a throwaway script, especially with multiple developers. The biggest self-inflicted wound is **`any`**: it tells the compiler "stop checking this," and sprinkling it (or papering over errors with `as` casts) gives you all the ceremony of TypeScript with none of the protection. Enable `strict` mode, prefer `unknown` over `any` when a type is genuinely unknown (it forces you to narrow before use), and treat each `any` as a small debt. Used with discipline, TypeScript is one of the highest-leverage choices in a Node project.
Next
Configure a TypeScript Node project end to end: [Setting Up a TS Node Project](/nodejs/typescript-setup).