What Disappears
Type annotations are stripped
let count: number = 3;
const label: string = "items";
console.log(count, label); let count = 3;
const label = "items";
console.log(count, label); Every type annotation is removed wholesale during compilation. The emitted JavaScript is byte-for-byte what you would have written by hand — the annotations existed only to satisfy the checker and have zero runtime cost or presence.
Interfaces and type aliases vanish
interface User {
name: string;
age: number;
}
const ada: User = { name: "Ada", age: 36 };
console.log(ada.name); // the interface compiled to nothing at all
const ada = { name: "Ada", age: 36 };
console.log(ada.name); interface and type declarations emit no code whatsoever. There is no User value at runtime — you cannot reference it, log it, or test against it. They are pure compile-time contracts that evaporate.Generics are erased
function first<T>(items: T[]): T {
return items[0];
}
console.log(first<number>([10, 20, 30])); function first(items) {
return items[0];
}
console.log(first([10, 20, 30])); Type parameters are erased — there is no
T at runtime and no way to read it inside the function. This is why a TypeScript generic cannot do new T() or inspect T, unlike the reified generics of C# or Java.as casts are no-ops
const raw: unknown = 42;
const text = raw as string;
console.log(typeof text); // "number" — the cast did nothing
console.log((text as any).toFixed(1)); const raw = 42;
const text = raw; // nothing to convert — still a number
console.log(typeof text);
console.log(text.toFixed(1)); The
as operator performs no conversion and no runtime check — it only silences the type checker. Here a number masquerades as a string; the lie survives until something actually inspects the value. as is the single most common source of false confidence in TypeScript.satisfies leaves no trace
const config = {
port: 8080,
host: "localhost",
} satisfies Record<string, string | number>;
console.log(config.port); const config = {
port: 8080,
host: "localhost",
};
console.log(config.port); The
satisfies operator checks that a value conforms to a type while keeping its narrow inferred type — but, like as, it is purely a compile-time assertion and is erased entirely from the output.The ! assertion inserts no check
const values: (number | null)[] = [1, null, 3];
const first = values[0]!; // "trust me, not null"
console.log(first + 1); const values = [1, null, 3];
const first = values[0]; // the ! is gone — no guard emitted
console.log(first + 1); The non-null assertion
! tells the compiler to treat a value as non-null and then vanishes — no runtime check is inserted. If the value really is null, you get the same runtime error you would in untyped JavaScript.private is compile-time only
class Account {
private balance = 100;
}
const account = new Account();
console.log((account as any).balance); // 100 — reachable at runtime class Account {
balance = 100; // the `private` modifier left no trace
}
const account = new Account();
console.log(account.balance); TypeScript's
private, protected, and readonly are enforced only by the checker. At runtime the field is an ordinary public property, reachable through a bracket or an as any. For privacy the runtime actually enforces, use the next example.#private fields are real
class Account {
#balance = 100;
get balance(): number {
return this.#balance;
}
}
const account = new Account();
console.log(account.balance); // truly inaccessible from outside class Account {
#balance = 100;
get balance() {
return this.#balance;
}
}
const account = new Account();
console.log(account.balance); The ECMAScript
#private field is enforced by the runtime itself, not the type checker — reaching it from outside is a hard error even in plain JavaScript. Reach for # when privacy must be real rather than advisory.What Actually Emits Code
enum emits a real object
enum Direction {
North,
South,
}
console.log(Direction.North); // 0
console.log(Direction[0]); // "North" // a numeric enum compiles to an object with a reverse mapping:
const Direction = {};
Direction[Direction["North"] = 0] = "North";
Direction[Direction["South"] = 1] = "South";
console.log(Direction.North);
console.log(Direction[0]); Unlike a type, an
enum compiles to a real runtime object — a numeric enum even builds a reverse map from value back to name. It has size and appears in the output, surprising developers who assume every TypeScript construct disappears.const enum is inlined instead
const enum Size {
Small = 1,
Large = 2,
}
console.log(Size.Large); // a `const enum` emits NOTHING — each use site is inlined:
console.log(2); A
const enum is the erased counterpart: the compiler inlines each member's literal value and emits no object at all. It buys enum-like names without the runtime weight of a plain enum — at the cost of cross-module inlining caveats.namespace emits an IIFE
namespace Geometry {
export const pi = 3.14159;
export function area(radius: number): number {
return pi * radius * radius;
}
}
console.log(Geometry.area(2)); var Geometry;
(function (Geometry) {
Geometry.pi = 3.14159;
function area(radius) {
return Geometry.pi * radius * radius;
}
Geometry.area = area;
})(Geometry || (Geometry = {}));
console.log(Geometry.area(2)); A
namespace is not a type — it emits an immediately-invoked function that builds a real object. Like enum, it is one of the few TypeScript-only keywords that generates running code rather than vanishing.Parameter properties generate assignments
class Point {
constructor(public x: number, public y: number) {}
}
const point = new Point(3, 4);
console.log(point.x, point.y); class Point {
constructor(x, y) {
this.x = x; // generated from the `public` modifiers
this.y = y;
}
}
const point = new Point(3, 4);
console.log(point.x, point.y); A
public/private/readonly modifier on a constructor parameter is the one place an access modifier does emit code — the compiler inserts the this.x = x assignments. The shorthand is sugar for ordinary field assignment.Types Do Not Exist at Runtime
You cannot instanceof an interface
interface Duck {
quack(): string;
}
function isDuck(value: any): value is Duck {
return typeof value?.quack === "function";
}
const maybe = { quack: () => "Quack!" };
console.log(isDuck(maybe)); function isDuck(value) {
return typeof value?.quack === "function";
}
const maybe = { quack: () => "Quack!" };
console.log(isDuck(maybe)); There is no
value instanceof Duck — an interface has no runtime representation. Runtime checks must inspect real values: a property test like this user-defined type guard (value is Duck), or instanceof against an actual class.External data is any, and it lies
const json = '{"age": "not a number"}';
const user = JSON.parse(json) as { age: number };
console.log(user.age * 2); // NaN — the type was a promise the data broke const json = '{"age": "not a number"}';
const user = JSON.parse(json);
console.log(user.age * 2); JSON.parse, fetch, and localStorage all hand you any. Annotating the result asserts a shape the compiler cannot verify, so malformed external data sails straight through to a runtime bug. The fix is validation at the boundary — see the JSDoc & validation section.Indexing returns undefined despite the type
const scores: number[] = [10, 20];
const value: number = scores[5];
console.log(value); // undefined, despite the number type
console.log(value + 1); // NaN const scores = [10, 20];
const value = scores[5];
console.log(value);
console.log(value + 1); Array and object indexing yields
undefined for a missing slot, but the type says number — the compiler trusts the annotation, not reality. The noUncheckedIndexedAccess flag tightens this at compile time, yet the runtime behavior is unchanged.?. and ?? are real JavaScript
const user: { address?: { city: string } } = {};
console.log(user.address?.city ?? "unknown"); const user = {};
console.log(user.address?.city ?? "unknown"); Optional chaining
?. and the nullish-coalescing ?? are easy to mistake for TypeScript features, but they are ECMAScript operators that emit real code and run in plain JavaScript. They survive compilation precisely because they are not type constructs.Types in Plain JavaScript (JSDoc)
JSDoc type annotations
function add(a: number, b: number): number {
return a + b;
}
console.log(add(2, 3)); /**
* @param {number} a
* @param {number} b
* @returns {number}
*/
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); With
// @ts-check or the checkJs option, the TypeScript compiler type-checks plain JavaScript using JSDoc annotations — full type safety with no build step. Svelte and other projects ship this way, editing .js files that tsc still validates.JSDoc typedef for shapes
interface User {
name: string;
age: number;
}
const ada: User = { name: "Ada", age: 36 };
console.log(ada.name); /** @typedef {{ name: string, age: number }} User */
/** @type {User} */
const ada = { name: "Ada", age: 36 };
console.log(ada.name); A JSDoc
@typedef expresses an interface, @template expresses generics, and import('./types').Foo references types from other files. The whole type vocabulary is available in comments, understood by both the editor and tsc — no .ts file required.Runtime validation is the real safety
function parseAge(json: string): number {
const data = JSON.parse(json) as { age: number };
if (typeof data.age !== "number") {
throw new TypeError("age must be a number");
}
return data.age;
}
console.log(parseAge('{"age": 36}')); function parseAge(json) {
const data = JSON.parse(json);
if (typeof data.age !== "number") {
throw new TypeError("age must be a number");
}
return data.age;
}
console.log(parseAge('{"age": 36}')); Because types are erased, the only runtime guarantee comes from an actual runtime check. A hand-written guard like this — or a schema library such as zod that infers the static type from the validator — is what makes external data genuinely safe. The annotation never could.