#What TypeScript is (and isn't)
AddedTypeScript is JavaScript plus a static type layer. You write ordinary JavaScript and add type annotations, interfaces and type aliases on top. Nearly every valid JavaScript file is also syntactically valid TypeScript — the compiler may complain about types in it, but it understands it. The type layer exists only for tools: the compiler, your editor, your linter.
The compiler (tsc) does two independent jobs. It checks: reads your code, works out the type of every expression, and reports anything that doesn't fit. And it emits: strips the type syntax out and writes plain JavaScript, optionally rewriting newer syntax for older runtimes. These two jobs barely talk to each other. By default tsc writes the JavaScript even when the check found errors, and tools like esbuild, Vite, tsx and Node itself do only the second job.
A good mental model is scaffolding around a building. The types help you build the thing correctly, but they are taken down before the building opens. At runtime there is no interface, no type, no annotation — just the JavaScript that was underneath. That one fact explains most of TypeScript's "surprises": you can't check a type at runtime, a wrong JSON.parse result isn't caught, and two types with the same shape are interchangeable.
export interface User {
id: number;
name: string;
}
export type Role = "admin" | "viewer";
export function greet(user: User, role: Role): string {
return `Hello ${user.name} (${role})`;
}
const rohit: User = { id: 1, name: "Rohit" };
console.log(greet(rohit, "admin")); // Hello Rohit (admin)greet// What tsc emits (user.js)
export function greet(user, role) {
return `Hello ${user.name} (${role})`;
}
const rohit = { id: 1, name: "Rohit" };
console.log(greet(rohit, "admin"));- The checker reads
greetand records its contract: the first argument must have a numericidand a stringname, the second must be exactly"admin"or"viewer", and the result is astring. - It checks
rohitagainstUser(both properties present, right types) and then the callgreet(rohit, "admin")against the contract. Everything fits, so there are no errors. Passing"owner"would have been TS2345: "Argument of type '\"owner\"' is not assignable to parameter of type 'Role'." - Then the emitter runs.
export interface Userandexport type Rolevanish completely — there is nothing to emit for them. The annotations: User,: Roleand: stringare deleted. - What's left is the JavaScript you'd have written by hand. Note the output is ES module syntax with modern template literals, because nothing told
tscto rewrite them (see target vs module). - This is type erasure: TypeScript adds zero runtime code for types and zero runtime cost. The flip side is that nothing type-related survives to protect you at runtime.
Because types are erased, TypeScript can only check what it can see in the source. Data that arrives at runtime — an API response, JSON.parse, localStorage, a form field — is whatever it is, regardless of the type you gave it.
interface User {
id: number;
name: string;
}
const user: User = JSON.parse('{"id":"42"}'); // compiles: JSON.parse returns any
console.log(user.id, typeof user.id); // 42 string — the type said number
console.log(user.name.toUpperCase()); // compiles, then TypeError at runtime
function isUser(value: unknown) {
return value instanceof User; // ❌ 'User' only refers to a type, but is being used as a value here.
}JSON.parseis declared to returnany, andanyis assignable to everything. So annotating the result asUseris accepted with no check at all — you've made a claim, not a verification.- At runtime the object is
{ id: "42" }.user.idis the string"42"(the type saidnumber) anduser.nameisundefined. user.name.toUpperCase()type-checks fine (the checker believesnameis astring) and crashes with "TypeError: Cannot read properties of undefined (reading 'toUpperCase')".- You can't fix this with
instanceof User:Useris an interface, it doesn't exist at runtime, so there is nothing to compare against. The compiler says so — TS2693: "'User' only refers to a type, but is being used as a value here."instanceofonly works with classes, which are runtime values. - The real fix is to treat outside data as
unknownand validate it with code that runs (hand-written checks or a schema library) — see unknown and Validating runtime data.
Structural typing
Languages like Java and C# are nominal: a Vector is a Point only if it's declared to be (implements Point). TypeScript is structural: a value fits a type if it has the right shape — the required properties with compatible types. Names and declarations don't matter. This is a natural fit for JavaScript, where objects are usually created from literals rather than classes.
interface Point {
x: number;
y: number;
}
class Vector {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function distanceFromOrigin(p: Point): number {
return Math.hypot(p.x, p.y);
}
distanceFromOrigin(new Vector(3, 4)); // 5 — Vector never mentions Point
const reading = { x: 6, y: 8, label: "sensor" };
distanceFromOrigin(reading); // 10 — extra properties are fine
distanceFromOrigin({ x: 1, y: 1, label: "inline" }); // ❌ Object literal may only specify known properties, and 'label' does not exist in type 'Point'.distanceFromOriginasks for aPoint: anything with a numericxand a numericy.new Vector(3, 4)is accepted even thoughVectorhas noimplements Point. The checker compares shapes:Vectorhasx: numberandy: number, so it is aPointas far as TypeScript cares. Result:Math.hypot(3, 4)→5.readinghas an extralabel. That's allowed — a type describes the minimum an object must have, not everything it may have.Math.hypot(6, 8)→10.- The last call passes the same kind of object, but as a fresh object literal written right at the call. Here TypeScript applies an extra rule, excess property checking: an unknown property in a literal is almost always a typo, so it reports TS2353. Same shape, different verdict — because of where the object came from (see Excess property checking).
- The takeaway: compatibility is decided by structure. Two unrelated types with the same members are interchangeable, which is convenient but also means
type UserId = stringandtype OrderId = stringare the same type.
- Not a runtime. Nothing executes TypeScript. Node, Deno, Bun and browsers run JavaScript — some of them strip the types for you first, but that's still erasure.
- Not runtime validation. No checks are inserted into the output. A function declared to take a
numberwill happily receive a string from untyped code. - Not a polyfill. Setting an old
targetrewrites syntax, but never adds missing functions likeArray.prototype.at(see target vs module). - Not (usually) a change in behaviour. Erasing types leaves the program doing exactly what it did. The exceptions are the few features that generate code —
enum,namespacewith values, constructor parameter properties — which is why modern setups often ban them witherasableSyntaxOnly(see Running TypeScript).