Where JavaScript came from, and why TypeScript exists
Brendan Eich wrote JavaScript in ten days in 1995, for Netscape Navigator, to handle things like form validation and small bits of page interactivity. That's it.
Nobody designed it to run trading platforms, or single-page apps with hundreds of thousands of lines, or servers handling millions of requests. The looseness that makes JavaScript so easy to pick up (anything can be anything, objects reshape themselves on the fly, functions don't care how many arguments you hand them) comes directly from that original, much smaller job.
The trouble started once JavaScript outgrew that job. Once teams were building huge single-page apps, once Node.js (2009) put JavaScript on the server too, the same flexibility that made a 50-line script forgiving started causing real damage in a 50,000-line codebase.
Nothing stops one developer from handing a function the wrong shape of object and finding out three files away, at runtime, in production.
Anders Hejlsberg, who'd already designed C# and Delphi at Microsoft, built TypeScript to fix exactly that problem.
It shipped in 2012 as an optional layer of static types on top of JavaScript, with one hard constraint: it couldn't change how JavaScript actually behaves at runtime. Everything else about TypeScript follows from that one rule.
The philosophical split
JavaScript's whole design leans on freedom. Variables hold whatever you put in them, objects can be reshaped whenever, and a function will happily accept the wrong number of arguments without complaint.
That's genuinely useful for prototyping and small scripts, and it's part of why the language spread so fast.
TypeScript's answer isn't to take that freedom away. It layers on a type system that's gradual (you can adopt it one file at a time), optional (any lets you opt out whenever you need to), and structural (compatibility is based on shape, not some declared class hierarchy).
Hejlsberg has described the goal as adding a thin, erasable layer of syntax for types on top of JavaScript, not inventing a new language.
What static typing actually catches
Static typing means the compiler works out, before your code ever runs, what type of value is flowing through every variable and function. Take this:
function getUserEmail(user: { email: string }): string {
return user.email.toLowerCase();
}
getUserEmail({ name: "Alex" });
// Compile-time error: Property 'email' is missing
In plain JavaScript, that exact bug doesn't surface until user.email.toLowerCase() actually runs and throws "Cannot read properties of undefined." In TypeScript you see it as a red squiggle the moment you write the call, often before you've even saved the file.
But TypeScript isn't fully "sound." A truly sound type system guarantees that if the compiler says a value is type X, it really is type X at runtime, always.
TypeScript breaks that promise in a few deliberate places, mostly so it can interoperate cleanly with existing JavaScript. The escape hatch any is the obvious one:
const arr: number[] = [1, 2, 3];
const anyArr: any[] = arr;
anyArr.push("not a number"); // no error, any bypasses checks
There are quieter unsoundness holes too. Array index access isn't checked against bounds by default, so arr[10] is typed as the element type even though it might genuinely be undefined.
Function parameters have some bivariance quirks in certain contexts. TypeScript accepts all this because a fully sound system would be too restrictive for how real JavaScript actually gets written, with duck-typed APIs and dynamic property access everywhere.
Three types sit at the edges of this system and are worth knowing well.
any opts a value out of checking entirely (overuse it and your .ts file is basically .js with extra syntax). unknown is the safer version: you can assign anything to it, but you can't use it until you narrow the type first.
function handleInput(input: unknown) {
if (typeof input === "string") {
input.toUpperCase(); // fine now, narrowed to string
}
}
And never represents values that genuinely can't occur, which is how experienced codebases get compile-time proof that every branch of a union has been handled:
function assertUnreachable(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
type Shape = { kind: "circle" } | { kind: "square" };
function area(s: Shape) {
switch (s.kind) {
case "circle": return 1;
case "square": return 2;
default: return assertUnreachable(s); // errors if a new variant shows up unhandled
}
}
Shape matters more than name
This is one of the least understood parts of how TypeScript works. Languages like Java or C# use nominal typing: two types are only compatible if one explicitly says it implements or extends the other.
TypeScript does something closer to duck typing, just enforced at compile time. If two types have the same shape, they're compatible, full stop, regardless of what they're called.
interface Point2D {
x: number;
y: number;
}
class Vector {
constructor(public x: number, public y: number) {}
}
function printPoint(p: Point2D) {
console.log(`${p.x}, ${p.y}`);
}
printPoint(new Vector(1, 2)); // fine, Vector has the shape Point2D wants
printPoint({ x: 1, y: 2, z: 3 }); // also fine, extra properties don't hurt
That maps onto how JavaScript objects actually behave at runtime (they're just bags of properties, nothing "declares" a type), and it's why a nominal system would have fought against the language constantly.
In practice this means TypeScript interfaces are best read as contracts about shape rather than classes to inherit from. Two libraries that have never heard of each other can each define their own User interface, and as long as the shapes line up, values pass between them without any conversion step.
How much TypeScript figures out for you
You don't have to annotate everything. The inference engine works hard to deduce types on its own, which keeps TypeScript code looking close to ordinary JavaScript. A few patterns worth knowing:
When you pass a callback into a known API, TypeScript infers the parameter types from context. Write window.addEventListener("click", (event) => ...) and event is already typed as MouseEvent, no annotation needed.
TypeScript also tracks how a variable's type changes as it moves through your conditionals, something usually called narrowing:
function format(value: string | number) {
if (typeof value === "string") {
return value.trim();
}
return value.toFixed(2);
}
Combine narrowing with a shared "tag" property and you get discriminated unions, probably TypeScript's most useful everyday pattern:
type Result =
| { status: "success"; data: string }
| { status: "error"; message: string };
function handle(result: Result) {
if (result.status === "success") {
console.log(result.data);
} else {
console.log(result.message);
}
}
There's a gotcha worth knowing about const, too: TypeScript widens literal types by default.
let a = "hello"; // type: string
const b = "hello"; // type: "hello"
const config = { mode: "dark" }; // { mode: string }
const config2 = { mode: "dark" } as const; // { readonly mode: "dark" }
This trips people up constantly with things like Redux action types or API response shapes, where you actually need the literal type preserved for exhaustiveness checks, and as const is the fix.
Where the type system stops being a formality
A handful of features don't exist in JavaScript at all; they're only meaningful at compile time. Generics let you write reusable code that keeps a specific type attached all the way through:
function wrapInArray<T>(value: T): T[] {
return [value];
}
interface ApiResponse<T> {
data: T;
status: number;
}
Mapped types transform every property of an existing type programmatically (this is literally how Partial<T> and Readonly<T> are built under the hood):
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Partial<T> = { [K in keyof T]?: T[K] };
Conditional types branch on a condition at compile time:
type IsString<T> = T extends string ? true : false;
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;
And template literal types, added in TypeScript 4.1, let you build string types compositionally:
type Direction = "top" | "bottom" | "left" | "right";
type Margin = `margin-${Direction}`;
// "margin-top" | "margin-bottom" | "margin-left" | "margin-right"
Tailwind's type definitions and most form libraries lean on this heavily to validate string-based APIs at compile time, something plain JavaScript simply can't do. A few of the built-in utility types you'll run into constantly:
| Utility | Effect |
|---|---|
| Partial<T> | all properties optional |
| Required<T> | all properties required |
| Pick<T, K> | select a subset of properties |
| Omit<T, K> | exclude a subset of properties |
| Record<K, V> | build an object type from keys and a value type |
| ReturnType<T> | extract a function's return type |
From .ts to actually running code
The pipeline looks roughly like this: your .ts source goes through the TypeScript compiler (or a faster transpiler like esbuild or swc), gets type-checked (only tsc actually does this; the fast transpilers skip it), has its type annotations stripped out completely, gets down-leveled for whatever JS version you're targeting, and comes out as plain .js that runs in V8 or SpiderMonkey exactly like anything you'd hand-write.
Two things about that pipeline matter more than they first seem to. Type erasure is total: nothing about your types survives into the emitted JavaScript, which is why TypeScript adds zero runtime overhead, but also why you can't do something like typeof SomeInterface in your actual logic.
If you need to validate that some data really matches a type at runtime (an API response, say), you need a separate library like zod, because TypeScript's guarantees stop dead at compile time.
Type-checking and transpiling are also two separate jobs now.
Fast bundlers strip types without checking them at all, for speed, and push the actual checking into a parallel tsc --noEmit step in CI. So a build can succeed through your bundler while your codebase still has real type errors sitting in it, because the two concerns have been split apart on purpose.
JavaScript has no equivalent step by default. It runs directly, though most production JavaScript still goes through Babel or a bundler anyway, just without any type-checking involved.
What JavaScript is actually doing under the hood
To appreciate what TypeScript is guarding against, it helps to look at what JavaScript does on its own. Coercion is the classic example:
"5" + 3 // "53"
"5" - 3 // 2
[] + [] // ""
[] + {} // "[object Object]"
These rules cause a steady trickle of subtle bugs, and TypeScript's strict mode flags a lot of the situations that lead to them.
Inheritance is another source of surprises. JavaScript objects inherit through a prototype chain, not a classical hierarchy; class syntax is really just sugar sitting on top of that mechanism.
You can reassign an object's prototype at runtime if you want to, which is flexible but also means "what type is this object, really" can be a genuinely fuzzy runtime question, exactly the kind of question a structural type system exists to pin down.
Then there's this, decided by how a function gets called, not where it was defined:
const obj = {
value: 42,
getValue() { return this.value; }
};
const fn = obj.getValue;
fn(); // undefined, `this` got lost along the way
TypeScript can catch some of this (with noImplicitThis, or by typing this parameters explicitly), but it's fundamentally a runtime behavior, so the protection is only partial.
Dynamic property access, where you build a key at runtime, works fine in plain JS but only gets partial safety in TypeScript through index signatures.
Where each language actually stops you from shipping a bug
| Bug | JavaScript | TypeScript |
|---|---|---|
| Typo in a property name | silent undefined, crashes later | flagged immediately in the editor |
| Wrong argument type | may coerce silently or crash | compile error |
| Missing required property | crashes when accessed | compile error |
| Calling a method on null/undefined | TypeError at runtime | caught with strictNullChecks |
| Unhandled case in a switch | silent fallthrough bug | caught via exhaustiveness checking |
| Bad logic, race conditions | same in both | same in both |
That last row is worth sitting with. TypeScript doesn't catch logic errors or bad architecture, only mismatches in the shape and type of data flowing through your program. A perfectly type-safe TypeScript app can still be completely wrong.
If I had to pick the single highest-value flag in the whole language, it'd be strictNullChecks. Null and undefined access is one of the most common crashes in JavaScript, and this flag forces you to actually handle it:
function greet(name: string | null) {
if (name === null) return "Hello, stranger";
return name.toUpperCase();
}
Tooling, and why editors feel smarter with TypeScript
Because the compiler holds a full model of every type in your program, editors can offer autocomplete that actually knows what properties exist on a value, safe rename-refactoring across an entire codebase instead of a risky find-and-replace, and inline errors before you've even run anything.
JavaScript can get a partial version of this through JSDoc annotations, which tsserver will happily type-check even in a .js file with // @ts-check at the top, though it's more verbose and less pleasant for anything involving generics.
None of this shows up as a runtime cost. Type annotations are erased before anything executes, so there's no performance difference between equivalent TypeScript and JavaScript once it's running.
Where the cost actually lands is build time (large monorepos can take a while for tsc to check) and editor responsiveness (tsserver re-analyzing types on every keystroke can lag in codebases with heavy generic use). Bundle size is unaffected either way, since types contribute zero bytes to what ships.
The ecosystem has basically already decided
Angular is written in TypeScript and expects you to use it. Vue 3 was rewritten in TypeScript. Next.js and Nuxt configure it out of the box. NestJS leans on it heavily for decorators. React stays agnostic but TypeScript is the default for new projects at this point.
Most npm packages ship their own type definitions now, or get community-maintained ones through DefinitelyTyped, so even libraries that were never written with TypeScript in mind can usually be consumed with full type safety.
Adopting it without a rewrite
Because TypeScript is a strict superset, you don't have to convert a whole codebase at once. The usual path: rename files from .js to .ts one at a time, fixing errors as they come up, or use allowJs and checkJs to type-check plain .js files via JSDoc before renaming anything.
Start with strict: false, get things compiling, then turn flags like strictNullChecks on one at a time as the team works through the backlog.
Treat any as a deliberate, temporary escape hatch for genuinely hard-to-type legacy code rather than a blocker. Type the boundaries between modules first, since that's where mismatched interfaces cause the most damage.
This gradual path is a big part of why TypeScript caught on where earlier "typed JavaScript" efforts, like Google's Closure annotations or Facebook's Flow, didn't get nearly as far. The migration cost gets spread out instead of paid all at once.
The honest trade-offs
TypeScript isn't free. It adds real build tooling (a tsconfig, source maps, a compile step) even for something that might've been a five-line script.
The type system's more advanced corners (conditional types, mapped types, template literals) take real time to learn, and heavily generic code can get genuinely hard to read. Errors from a third-party library's .d.ts file are sometimes confusing and disconnected from anything in your own code.
And any means the safety net is only as good as the team's discipline; it's easy to undermine.
Plain JavaScript has its own costs, they just show up later. Refactoring confidence drops as a codebase grows, since nothing stops a rename from silently breaking some caller three files away.
Data shapes end up documented informally, if at all, relying on tribal knowledge instead of an enforced contract. And a whole class of production bugs, wrong argument order, undefined property access, has no compile-time defense at all.
Does this actually matter at your scale?
Several companies (Airbnb and Slack come up often, alongside Microsoft's own products) have talked publicly about moving large JavaScript codebases to TypeScript specifically because a meaningful share of their production bugs traced back to type mismatches: the wrong shape passed into a function, an unhandled undefined, an API contract that drifted out of sync between two teams.
If you're working solo on something small, that calculus looks different. There's no one else's mental model to stay in sync with, and the codebase probably isn't big enough for "what shape is this object, again" to become a real problem you can't just hold in your head.
What's coming
As of 2026 there's an active TC39 proposal to let JavaScript engines parse and ignore type syntax natively, essentially treating TypeScript-like annotations as valid no-op JavaScript. The goal isn't to make JavaScript statically typed; it's to let TypeScript-style code run directly in a browser or Node without a separate build step, since the engine would just skip over the annotations at parse time.
Node has already shipped experimental support for stripping type annotations from .ts files without full checking. This is moving fast enough that it's worth checking current TC39 and Node release notes rather than taking this as settled.
So which one do you actually reach for?
Small script, quick prototype, something only you'll ever touch? Plain JavaScript, and don't feel bad about it. Zero build step, nothing to configure, done.
Anything more than one person will read or extend, anything expected to grow, anything built on a framework where TypeScript is already the default (which by now is most of them)? TypeScript earns its keep. The safer refactors and the richer editor support start paying for themselves pretty quickly once a codebase has any real size to it.
Bottom line
JavaScript and TypeScript aren't really competing languages. TypeScript compiles down to JavaScript and erases every trace of itself before anything runs; the actual question is just when the cost of static typing (the tooling, the verbosity, the learning curve) is worth what it buys you (caught bugs, safer refactors, better tooling).
For small, short-lived, single-author code, it often isn't. For anything that's going to be maintained by more than one person over time, the industry has mostly already made up its mind.
Published via ZyVOP — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.