TypeScript Isn't "JavaScript With Types" — It's a Proof System You Already Own
TL;DR — TypeScript is JavaScript plus an erasable, compile-time type system. For senior engineers its real value isn't autocomplete; it's the ability to make illegal states unrepresentable, push validation to the boundary, and refactor large systems fearlessly.
- Model state with discriminated unions so impossible combinations cannot compile.
- Types are erased at runtime — validate untrusted input at the boundary (e.g. Zod) and derive the type from the schema.
- Use
satisfiesto validate without widening, and branded types for nominal safety in a structural language. strict: trueis the floor — also enablenoUncheckedIndexedAccess.- TypeScript 7.0 is a native Go port, ~10× faster than 6.0, with identical type-checking semantics.
Most "TypeScript vs JavaScript" articles sell you a spellchecker. Catch typos, get autocomplete, done. That pitch is true, boring, and roughly 5% of the actual value. If you've shipped JavaScript at scale for a decade, you didn't lose afternoons to typos — you lost weeks to invariants that silently broke three layers away from where the data went wrong.
Here's the reframe that matters once you're past junior: TypeScript is a lightweight, erasable proof system bolted onto a dynamic language. Every type you write is a small theorem the compiler checks before your code runs. The discipline isn't "annotate everything." It's "encode your invariants so that illegal states stop compiling." Done well, whole categories of runtime bug become unrepresentable — not caught, not logged, but impossible to express.
This article is for engineers who already know interface and <T>. We're going to the parts that actually change how you architect: making illegal states unrepresentable, the runtime erasure gap nobody warns juniors about, structural vs nominal typing, type-level programming as a real tool, and — because I've been burned — the honest costs.
This isn't just preference. A widely cited study, To Type or Not to Type: Quantifying Detectable Bugs in JavaScript (Gao, Bird & Barr), found that a static type system could have prevented roughly 15% of public bugs in the JavaScript projects they examined — bugs that shipped to users. Airbnb's engineering team, in a retrospective on their own migration, estimated that 38% of their bugs were preventable by TypeScript.
TypeScript vs JavaScript at a Glance
| Concern | JavaScript | TypeScript |
|---|---|---|
| Error detection | Runtime, often in production | Compile time, before commit |
| Invalid states | Representable (boolean soup) | Designed out via discriminated unions |
| Refactoring large code | Manual, risky | Compiler re-proves assumptions |
| API/data contracts | Implicit, drift silently | Explicit; enforce at boundary with validators |
| Tooling / autocomplete | Best-effort guesses | Type-driven, precise |
| Runtime cost | None | None — types are erased |
| Build step | Optional | Required (10× faster in native 7.0) |
1. The Mental Model: Make Illegal States Unrepresentable
This is the single idea that separates people who "use TypeScript" from people who design with it. Consider a request state you've written a hundred times in JS:
// The shape that ruins your week
interface RequestState {
isLoading: boolean;
data?: User[];
error?: Error;
}This type permits nonsense. { isLoading: true, data: [...], error: someError } type-checks perfectly — loading, succeeded, and failed simultaneously. Every component reading this state must defensively guard against combinations that should never exist. That defensive code is where bugs breed.
A discriminated union deletes the entire problem:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
function render(state: RequestState) {
switch (state.status) {
case "idle": return spinnerOff();
case "loading": return spinner();
case "success": return list(state.data); // data exists HERE, nowhere else
case "error": return banner(state.error); // error exists HERE, nowhere else
}
}Inside case "success", state.data is guaranteed present and state.error doesn't exist on the type at all. You physically cannot read a field that the current state shouldn't have. The four impossible states from the boolean-soup version were just designed out.
Exhaustiveness: the compiler as a change-detector
Now the part that pays dividends for years. Add a never assertion to the default branch:
function render(state: RequestState) {
switch (state.status) {
case "idle": return spinnerOff();
case "loading": return spinner();
case "success": return list(state.data);
case "error": return banner(state.error);
default: {
const _exhaustive: never = state; // compile error if a case is unhandled
return _exhaustive;
}
}
}The day a teammate adds { status: "retrying" } to the union, every non-exhaustive switch across the codebase fails to compile, with a precise error pointing at the assignment to never. This is refactoring with a safety net the size of the repo. In JavaScript, that same change is a silent fall-through you discover in production via a Sentry alert at 2 a.m. This pattern alone justifies the migration on any non-trivial state machine.
2. The Gap Juniors Don't Know Exists: Types Are Erased
If you remember one thing from this article, make it this: TypeScript types do not exist at runtime. The compiler strips every annotation, interface, and type. The JavaScript that ships has zero knowledge of your types. The naïve article's "interface as a team contract" example contains a landmine:
interface User { id: number; name: string; role: "admin" | "user"; }
const user: User = await fetchUser();
console.log(user.role); // TypeScript "ensures" this... at COMPILE time onlyThat annotation is a lie you told the compiler. At runtime, fetchUser() returns whatever the network actually sent — a 500 HTML page, a renamed field, role: "superadmin" from a backend deploy you didn't coordinate with. TypeScript believes you because you asserted it. The bug from the original article — "API returned id as string instead of number" — would sail straight through this code, because the type is fiction the moment data crosses the network boundary.
Parse, don't validate
The professional pattern is to validate untrusted input at the boundary and derive the static type from the validator, so the two can never drift:
import { z } from "zod";
const User = z.object({
id: z.number(),
name: z.string(),
role: z.enum(["admin", "user"]),
});
type User = z.infer<typeof User>; // static type DERIVED from the runtime schema
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
return User.parse(await res.json()); // throws loudly at the boundary, not deep in your UI
}Now there is a single source of truth. The runtime check and the compile-time type are generated from the same declaration — change one, the other follows. Untrusted data is validated once, at the edge, and everything downstream operates on a value the type system can finally be trusted about. This is the architecture pattern the basic "interfaces are contracts" framing completely misses: a contract only holds if someone enforces it, and at runtime TypeScript can't.
(The emerging Standard Schema spec lets Zod, Valibot, ArkType, etc. share one validation interface — worth tracking if you're choosing a validator today.)
3. Structural Typing — and the any/unknown Distinction That Bites
TypeScript is structurally typed: compatibility is by shape, not by name. Two unrelated types with the same members are interchangeable. This is mostly a feature — it's why duck typing and mocking are painless — but it has sharp edges seniors need to know.
type Meters = number;
type Feet = number;
function altitude(m: Meters) { /* ... */ }
const distanceInFeet: Feet = 30;
altitude(distanceInFeet); // ✅ compiles. Both are just `number`. Hello, Mars Climate Orbiter.Branded (nominal) types: escaping structural collisions
When you need values that are the same shape but semantically incompatible — user IDs vs order IDs, validated vs raw email, cents vs dollars — brand them:
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const asUserId = (s: string) => s as UserId;
function getUser(id: UserId) { /* ... */ }
const orderId = "ord_123" as OrderId;
getUser(orderId); // ❌ Type 'OrderId' is not assignable to 'UserId'
getUser(asUserId("u1")); // ✅The brand exists only in the type system (zero runtime cost — the intersection is erased), yet it gives you nominal safety on top of a structural language. I use this constantly for "this string has been validated/sanitized" so the type itself proves the validation ran.
unknown is the disciplined any
any doesn't just opt a value out of checking — it's contagious; it disables type-checking for everything it touches downstream. unknown is the top type you actually want: it accepts anything but lets you do nothing with it until you narrow. Set useUnknownInCatchVariables (on under strict since 4.4) and your catch (e) binds e: unknown, forcing you to prove it's an Error before reading .message — because in JS, throw "string" and throw 42 are legal and common.
4. satisfies: Validate Without Widening (4.9)
A subtle senior pain point: a type annotation widens your value, throwing away the precise literal information you wanted. satisfies checks conformance while preserving the narrow inferred type:
type Colors = "red" | "green" | "blue";
type RGB = [number, number, number];
const palette = {
red: [255, 0, 0],
green: "#00ff00",
bleu: [0, 0, 255],
//~~~~ ❌ typo caught — 'bleu' isn't a Color
} satisfies Record<Colors, string | RGB>;
palette.green.toUpperCase(); // ✅ still known to be `string`, not widened to `string | RGB`
palette.red.at(0); // ✅ still known to be the tuple
With const palette: Record<Colors, string | RGB> = {...} you'd catch the typo but lose the per-key narrowing — palette.green would be string | RGB and .toUpperCase() wouldn't type-check. satisfies gives you validation and precision. This is the tool for config objects, route tables, and design tokens where you want both "is this complete/correct?" and "remember exactly what I wrote."
5. Type-Level Programming Is a Real Tool, Not a Party Trick
The original article's generics section stops at <T>(a: T[], b: T[]): T[]. That's the on-ramp. The type system is itself a (Turing-complete) functional language, and the standard library of conditional types, infer, mapped types, and key remapping lets you derive types from other types instead of hand-maintaining parallel definitions.
A practical example — deriving the event-handler map from an events interface, so the two never drift:
interface DomainEvents {
userCreated: { id: string; email: string };
orderPlaced: { orderId: string; total: number };
paymentFailed: { orderId: string; reason: string };
}
// Mapped type + template-literal key remapping + a parameter typed by lookup
type Handlers = {
[K in keyof DomainEvents as `on${Capitalize<string & K>}`]:
(payload: DomainEvents[K]) => void;
};
/* Handlers is computed, never written by hand:
{
onUserCreated: (p: { id: string; email: string }) => void;
onOrderPlaced: (p: { orderId: string; total: number }) => void;
onPaymentFailed:(p: { orderId: string; reason: string }) => void;
}
*/Add an event to DomainEvents and the handler map grows itself, fully typed, with the correct payload wired to each handler. This is the same machinery powering the inference in libraries you already lean on — tRPC inferring client types from your router, Zod's z.infer, Prisma's generated model types, React Hook Form's field paths. Understanding infer and conditional types is what lets you read those libraries' types when they go sideways instead of staring at a 40-line error helplessly.
// The conditional-type + infer pattern, the atom these libraries are built from
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type Awaited2<T> = T extends Promise<infer R> ? R : T;
type A = ElementOf<number[]>; // number
type B = Awaited2<Promise<string>>; // string6. Generics, Properly: Constraints, const Params, and Variance
Three sharp tools the basic treatment never reaches.
const type parameters (5.0) — preserve literals without as const at the call site
declare function defineRoute<const T extends readonly string[]>(segments: T): T;
const r = defineRoute(["users", "settings"]);
// Inferred: readonly ["users", "settings"] — NOT string[]
// No `as const` needed by the caller; the API author guaranteed precision.Variance annotations in / out (4.7) — declare intent, get faster, clearer checks
interface Producer<out T> { make(): T; } // covariant: only produces T
interface Consumer<in T> { consume(arg: T): void; } // contravariant: only consumes T
interface InOut<in out T> { make(): T; consume(arg: T): void; } // invariantBeyond documentation, explicit variance can speed up the type-checker on large generic-heavy codebases, because the compiler no longer has to structurally infer variance each time. On a big monorepo that's a real build-time win.
Constrain, then key into the constraint
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(i => i[key]);
}
const ages = pluck([{ name: "A", age: 30 }], "age"); // number[], and "age" is autocompletedThe constraint K extends keyof T is what makes the IDE offer only real keys and infer the exact element type. This is the difference between a generic that documents a relationship and one that merely launders any.
7. Deterministic Cleanup: using and Explicit Resource Management (5.2)
TypeScript shipped the TC39 explicit-resource-management proposal ahead of the broader ecosystem. The using declaration auto-disposes a resource when scope exits — no manual try/finally, correct even on early return or throw, disposed in stack (LIFO) order:
function loggy(id: string): Disposable {
console.log(`open ${id}`);
return { [Symbol.dispose]() { console.log(`close ${id}`); } };
}
function work() {
using a = loggy("a");
using b = loggy("b");
// ...early return, throw, whatever
} // logs: close b, then close a — guaranteed, in reverse orderFor DB connections, file handles, locks, spans/traces, and test fixtures, this is the cleanest deterministic-teardown story JavaScript has ever had, and await using covers async disposal. It's the kind of language-level guarantee that's hard to retrofit safely in plain JS.
8. strict: true Is the Floor, Not the Ceiling
A lot of teams flip strict: true and assume they're done. They're not — several of the highest-value checks live outside strict and must be enabled deliberately:
| Flag | What it stops |
|---|---|
noUncheckedIndexedAccess | Treats arr[i] / record[key] as T | undefined. Kills the classic "index past the end / missing key returns undefined" crash. Not in strict. |
exactOptionalPropertyTypes | Distinguishes "property absent" from "property set to undefined". Matters for in checks and serialization. Not in strict. |
noImplicitOverride | Requires override keyword, so a renamed base method doesn't silently orphan a subclass method. |
noFallthroughCasesInSwitch | Catches missing break — pairs with the never exhaustiveness pattern above. |
// with noUncheckedIndexedAccess
const first = users[0]; // User | undefined ← forces you to handle empty array
const role = roleMap[input]; // Role | undefined ← forces you to handle unknown key
first.name; // ❌ Object is possibly 'undefined' — exactly the bug you wanted caughtYes, noUncheckedIndexedAccess is initially annoying. That annoyance is it surfacing every place your old JS would have thrown Cannot read properties of undefined. Turn it on for new projects from day one; introduce it behind a follow-up cleanup on legacy ones.
9. Migration at Scale (Beyond "Rename One File")
The original "rename a .js to .ts" advice is fine for a toy. For a real codebase with hundreds of thousands of lines, the strategy is different:
- Type-check JS in place first. Set
allowJs+checkJs, or add// @ts-checkto individual files, and annotate via JSDoc — you get a large fraction of the safety with zero file renames or build changes. Great for proving value to skeptics. - Adopt strictness in layers, not all at once. Start non-strict, get it green, then enable one flag at a time (
noImplicitAny→strictNullChecks→ the rest).strictNullChecksis usually the hardest and highest-value step. - Automate the bulk. Tools like
ts-migrate(built for Airbnb's migration) get you to compiling-with-anyfast; you then burn down theanys by module, prioritizing the bug-prone core. - Use project references / incremental builds. On a monorepo,
composite+ project references and--buildkeep type-checking from becoming a multi-minute tax. This is also where build-time perf becomes a real engineering concern (next section). - Quarantine
any, don't sprinkle it. Preferunknownat boundaries; reserveanyfor genuinely intractable spots and lint against new ones (@typescript-eslint/no-explicit-any).
10. The Honest Costs (Because Seniors Ask)
I'd distrust any "switch to TypeScript" article that pretends it's free. It isn't. The trade-offs you should walk in knowing:
- Type-checker performance is a real budget. Deeply recursive conditional types, giant unions, and clever inference can push
tscand your editor into multi-second lag. Diagnose withtsc --extendedDiagnosticsand--generateTrace. This cost was real enough that Microsoft ported the entire compiler to Go to kill it — see the next section. A maturing platform is one whose costs get retired, not one that pretends they never existed. - Type-level cleverness can become a liability. A 60-line generic that nobody on the team can modify is worse than a runtime check plus a comment. Types are code; they have a maintenance cost and a readability cost. Senior judgment is knowing when to stop — encode the invariant, then go home.
- The runtime gap never fully closes. As covered in §2, types prove nothing about data you didn't validate. Teams that forget this get a false sense of safety, which is arguably more dangerous than dynamic JavaScript's honest uncertainty.
- Build/tooling complexity. Source maps,
tsconfigsprawl, transpilation pipelines, declaration emit for published libraries — all real overhead, mostly one-time, but non-zero.
The verdict isn't "TypeScript always wins." It's that on anything with multiple contributors, a non-trivial lifespan, or a domain model worth getting right, the trade is overwhelmingly favorable — and the costs above are manageable with discipline.
11. The 2026 Plot Twist: TypeScript Goes Native (6.0 → 7.0)
If you're reading this as the version numbers turn over, the timing matters: TypeScript is shipping the single biggest engineering change in its history, and it directly retires the largest cost in §10.
What's actually happening
For over a year the team has been porting the compiler and language service from TypeScript-compiled-to-JavaScript to Go — native code plus shared-memory parallelism. The result splits across two releases:
- TypeScript 6.0 is the last release built on the existing JavaScript codebase. It's explicitly a bridge release between 5.9 and 7.0 — most of its changes exist to align your project for the jump. It deprecates legacy options (e.g.
--moduleResolution node/node10), removes long-dead ones (--moduleResolution classic), and adds thees2025target/lib. Anything 6.0 marks deprecated is removed outright in 7.0. - TypeScript 7.0 is the native Go build (the project nicknamed tsgo), and it is roughly 10× faster than 6.0 on real codebases. It's already at RC, in production at Bloomberg, Canva, Figma, Google, Notion, Slack, Vercel and others, with a stable release expected very soon after 6.0.
The detail seniors should clock: it's a port, not a rewrite
This is the part the hype threads gloss over. The Go codebase was methodically ported from the existing implementation, not reimagined from scratch. The type-checking logic is described as structurally identical to 6.0 and validated against the decade-old conformance test suite. Translation: same semantics, same inference, same errors — just an order of magnitude faster. That's what makes it a low-risk drop-in rather than a "wait two years for the dust to settle" migration. A from-scratch rewrite would have risked subtle behavioral drift across thousands of edge cases; a faithful port preserves them.
What 10× actually buys you
The cost I flagged in §10 — multi-second tsc runs, laggy editors on big monorepos — is precisely what evaporates. CI type-check stages that took minutes drop to seconds. The new language service is built on LSP and multithreaded, so editor responsiveness (auto-import, hovers, find-references) on million-line projects stops being a daily tax. For teams who deferred TypeScript partly on build-time grounds, that objection is now largely gone.
Try it in five minutes
# The RC, drop-in on the same package name:
npm install -D typescript@rc
npx tsc --version # Version 7.0.x-rc — then run tsc exactly as before
# Bleeding-edge nightlies (binary is named `tsgo`):
npm install -D @typescript/native-previewFor editors, the TypeScript Native Preview VS Code extension flips your workspace onto the Go language server. Because it's LSP-based, it slots into most modern editors and tooling.
One migration caveat: clear your 6.0 deprecation warnings before adopting 7.0 — the options 6.0 merely warns about (you can silence them temporarily with "ignoreDeprecations": "6.0") are gone entirely in 7.0. Treat 6.0's warnings as your 7.0 pre-flight checklist.
Frequently Asked Questions
Is TypeScript better than JavaScript?
For any codebase with multiple contributors or a lifespan beyond a few weeks, yes. TypeScript is a superset of JavaScript that adds a compile-time type system, letting you catch a whole class of bugs before runtime and refactor with confidence. For one-off scripts the overhead may not pay off, but at scale the trade is strongly in TypeScript's favor.
Does TypeScript run at runtime?
No. TypeScript types are erased during compilation, so the JavaScript that ships has zero knowledge of them. This is why you must validate untrusted input (such as API responses) at runtime with a schema validator like Zod, and derive the static type from that schema so the two never drift.
What is the satisfies operator in TypeScript?
Introduced in TypeScript 4.9, the satisfies operator checks that a value conforms to a type while preserving the value's narrow, inferred type. Unlike a type annotation, it validates correctness without widening, so you keep both error-checking and precise per-key types.
Is it worth migrating an existing JavaScript project to TypeScript?
Yes, and you do not need a rewrite. Enable allowJs and checkJs, add JSDoc types or // @ts-check to your most bug-prone files first, then adopt strictness flags one at a time. Tools like ts-migrate automate the bulk for large codebases.
What is TypeScript 7.0 and why is it faster?
TypeScript 7.0 is a native port of the compiler and language service from JavaScript to Go, using native code and shared-memory parallelism. It is roughly 10 times faster than TypeScript 6.0 while keeping type-checking semantics structurally identical, making it a low-risk drop-in upgrade.
Is strict mode enough in TypeScript?
No. Several of the highest-value checks live outside strict and must be enabled deliberately, including noUncheckedIndexedAccess (treats array and record access as possibly undefined) and exactOptionalPropertyTypes. Treat strict: true as the floor, not the ceiling.
Conclusion: Buy Yourself Proofs, Not Annotations
The junior framing is "TypeScript catches my mistakes." The senior framing is "TypeScript lets me encode invariants the compiler enforces, push validation to the boundary, and then refactor the interior fearlessly because the type system re-proves my assumptions on every save." Discriminated unions delete impossible states. Branded types add nominal safety to a structural language. satisfies validates without widening. The boundary parse pattern closes the runtime gap. Exhaustiveness checks turn every future change into a guided checklist instead of a production incident.
None of that is about typing faster or autocompleting better — those are pleasant side effects. It's about shifting an entire class of failures from runtime, in production, discovered by users to compile time, on your machine, discovered before commit. For any system you intend to still be maintaining next year, that shift is the whole game.
Concrete next step for an existing codebase: add // @ts-check and JSDoc types to your single most bug-prone module, then turn on noUncheckedIndexedAccess and read what it complains about. Every error it raises is a crash you were one bad input away from shipping.









