PONYλM2Modula-2

TypeScript.CodeCompared.To/JavaScript

An interactive executable cheatsheet comparing TypeScript and JavaScript

TypeScript 6.0.3 JavaScript
What Disappears
Type annotations are stripped
Start with the simplest possible case, because it sets the rule for the whole page: put the two columns side by side and the only difference is the annotations.
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
The comment in the JavaScript column is doing real work — there is genuinely nothing there. An interface produces no line of output at all.
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
<T> and the explicit <number> at the call site both disappear. Whatever the function does, it does without knowing what T was.
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
🚨 Read the output rather than the code. raw as string claims a number is a string, the program runs, and typeof reports the truth.
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
satisfies checks the value against a type without widening it — so config.port stays number rather than becoming string | number. Then it is gone.
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
The ! looks like it should do something. Compare the two columns and note that no check appears in the output — it is an instruction to the checker, not to the program.
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
The as any in the TypeScript column is the demonstration: it costs one cast to reach a private field, because the field was never private to the runtime.
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
The contrast with the previous row. #balance is ECMAScript syntax rather than TypeScript syntax, so it survives compilation and the runtime enforces it.
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
The JavaScript column is the compiler's actual output, and the double assignment is the reverse mapping — which is why Direction[0] gives back the name.
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
The whole JavaScript column is one line, and the constant has been substituted into it. Nothing named Size exists in the output.
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
The immediately-invoked function in the JavaScript column is generated, not written. This is the module pattern from before ECMAScript modules existed, still emitted verbatim.
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
Two public modifiers on the left produce two assignments on the right. This is the one access modifier that emits code rather than vanishing.
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
A user-defined type guard: value is Duck in the return type tells the checker that a true result narrows the argument, while the body does an ordinary property test.
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
🚨 The cast asserts a shape the data does not have, and the multiplication produces NaN rather than an error. This is the most common way a typed program goes wrong.
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
Index 5 of a two-element array. The annotation says number, the value is undefined, and nothing objected.
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
Both columns are identical below the annotation, which is the point: these two operators are JavaScript, not TypeScript.
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
The same function twice, with the types moved into a comment. A comment is not erased, because there was nothing to erase — the file is already JavaScript.
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
A @typedef in a comment declares a shape, and @type applies it. The editor and tsc both read these; the runtime never sees them.
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
The as in the TypeScript column is the lie; the typeof check below it is the truth. Only one of the two survives compilation, and it is the one doing the work.
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.
Where The Types Stop
any switches the checker off; unknown does not
any and unknown both mean "I do not know what this is". Only one of them makes you find out before using it.
const fromAny: any = "text"; try { console.log(fromAny.toFixed(2)); // compiles, and throws at run time } catch (error) { console.log("any:", (error as Error).constructor.name); } const fromUnknown: unknown = "text"; // console.log(fromUnknown.toFixed(2)); // will not compile if (typeof fromUnknown === "number") { console.log(fromUnknown.toFixed(2)); } else { console.log("not a number:", typeof fromUnknown); }
const fromAny = "text"; try { console.log(fromAny.toFixed(2)); // a TypeError, and nothing warned us } catch (error) { console.log("any:", error.constructor.name); } const fromUnknown = "text"; if (typeof fromUnknown === "number") { console.log(fromUnknown.toFixed(2)); } else { console.log("not a number:", typeof fromUnknown); }
any disables checking for every expression it touches and spreads outward — an any in a return type quietly untypes its callers. unknown is the same permissiveness on the way in with none on the way out: you must narrow it first, and the narrowing is real JavaScript that survives compilation. Prefer unknown at every boundary, and turn on noImplicitAny so the ones you did not ask for are errors.
Typing is structural, so a shape is enough
TypeScript checks whether the shape fits, not whether the type was declared — which is duck typing, verified at compile time. The commented line is the one exception, and it catches everyone.
interface Point { x: number; y: number } // Nothing declares that this satisfies Point. Having the shape is enough. const anything = { x: 1, y: 2, extra: "ignored" }; const point: Point = anything; console.log(point.x + point.y); // But an object LITERAL is checked for excess properties: // const direct: Point = { x: 1, y: 2, extra: "no" }; // will not compile
// None of this exists at run time. An object is its properties, and // nothing records what shape it was supposed to have. const anything = { x: 1, y: 2, extra: "ignored" }; const point = anything; console.log(point.x + point.y);
The exception is excess property checking: an object literal assigned directly to a typed target is rejected for extra properties, while the same object through a variable is accepted. That is a deliberate heuristic for catching typos in configuration objects, not a rule about types — which is why moving the literal into a variable silences it. Neither behavior leaves any trace in the output.
Everything from outside arrives untyped
Every entry point into a program — parsed JSON, a network response, an environment variable, stored state — arrives as any, and annotating it is a promise rather than a check.
// Each of these hands you 'any' and lets you annotate it into anything: // // const settings = JSON.parse(text) as Settings; // const user = await response.json() as User; // const port = Number(process.env.PORT); // const stored = JSON.parse(localStorage.getItem("state")!) as State; // // All four compile. All four are assertions about data the compiler // has never seen. const raw = '{"port": "8080"}'; const settings = JSON.parse(raw) as { port: number }; console.log(settings.port * 2); // NaN: the JSON had a string
const raw = '{"port": "8080"}'; const settings = JSON.parse(raw); console.log(settings.port * 2); // The plain JavaScript is honest about what it knows, which is // nothing. The TypeScript version knows nothing and says otherwise.
This is where a typed codebase actually breaks, and the fix is architectural rather than a flag: validate once at the boundary and trust the types inside it. A schema library such as zod or valibot is the usual answer because it inverts the relationship — you write the validator, and the static type is inferred from it, so the two cannot drift apart. Note also process.env.PORT is typed string | undefined and is frequently asserted away with !, which is the same lie in a shorter form.
Narrowing is real code, and it stays
The two bodies are identical. A discriminated union is checked by reading a real property with a real comparison, so the narrowing is ordinary JavaScript that survives compilation.
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number }; function area(shape: Shape): number { if (shape.kind === "circle") return 3.14159 * shape.radius ** 2; return shape.side ** 2; } console.log(area({ kind: "square", side: 3 }));
function area(shape) { if (shape.kind === "circle") return 3.14159 * shape.radius ** 2; return shape.side ** 2; } console.log(area({ kind: "square", side: 3 }));
That is the distinction worth holding on to across this whole page: typeof, instanceof, in, a truthiness check and a comparison against a discriminant are all runtime operations the checker happens to understand. as, ! and satisfies are compile-time assertions that leave nothing behind. A codebase that narrows survives bad data; one that asserts does not.
What Actually Ships
A .d.ts is types with no code
Compiling a library produces two files: the JavaScript that runs, and a declaration file that carries the types for whoever imports it. Only one of them ever executes.
// tsc --declaration emits a .d.ts beside the .js: // // greet.ts → greet.js the code that runs // greet.d.ts the types, for other packages // // greet.d.ts contains only: // // export declare function greet(name: string): string; // // It has no body, because there is nothing to run — the body is in // the .js file next to it. console.log("two files, one of which never executes");
// The .js half is what a consumer actually loads: // // function greet(name) { // return "hello, " + name; // } // // A JavaScript-only package with no .d.ts is typed as 'any' by every // TypeScript consumer — which is what @types/* packages exist to fix. console.log("only this half runs");
Two consequences worth knowing. A published .d.ts is hand-written or generated, and unverified against the code it describes — a wrong declaration in a dependency is a lie the compiler believes, which is the most common source of "the types said this would work". And a JavaScript-only package appears as any unless somebody wrote types for it, which is what the community-maintained @types/* packages on DefinitelyTyped are.
Declarations merge, which is how globals get typed
Declaring the same interface twice is not an error — the declarations combine. It is the mechanism behind every library that adds a property to Window or extends an existing type.
// Two declarations of the same interface MERGE rather than conflict: interface Window { title: string } interface Window { subtitle: string } // so a value must satisfy both halves: const shape: Window = { title: "a", subtitle: "b" }; console.log(shape.title, shape.subtitle); // which is what lets a library add to a global type it does not own: // // declare global { // interface Window { myPlugin: Plugin } // }
// All of it is erased. The merged interface never existed as a value, // so the emitted code has no idea two declarations were combined. const shape = { title: "a", subtitle: "b" }; console.log(shape.title, shape.subtitle);
This is a feature with no JavaScript counterpart at all, and it has a sharp edge: a type can be modified by a package you did not know was installed, and the change is invisible in your source. It is also why type aliases and interface are not interchangeable — a type cannot be reopened, which makes it the safer default for anything you do not intend others to extend.
import type disappears; import does not
Whether an import survives compilation depends on whether anything used it as a value, and the compiler decides per module — which is fine until the module has side effects.
// A plain import of something used only as a type MAY be elided, and // whether it is depends on compiler settings — which is a problem when // the module has side effects. // // import { User } from "./types"; // elided, maybe // import type { User } from "./types"; // elided, guaranteed // import { type User, load } from "./api"; // one specifier, mixed // // verbatimModuleSyntax makes the rule simple: what you wrote is what // is emitted, so 'import type' is erased and everything else stays. console.log("the import is the ambiguous part");
// The emitted JavaScript either has the import or does not, and that // difference decides whether the module's side effects run. // // require("./types"); // still evaluated // // (nothing) // never loaded // // Which is why a bundler and tsc disagreeing about elision produces // the classic "works in dev, breaks in production" import bug. console.log("presence or absence, and it matters");
The habit worth adopting is to write import type for every type-only import and turn on verbatimModuleSyntax, which makes emission literal: what you wrote is what ships. That removes the ambiguity for bundlers and transpilers, which each compile one file at a time and cannot see whether a name was used as a type in another module. It is also required by isolatedModules, which every esbuild- and swc-based toolchain effectively assumes.
Checking Is a Separate Step
🚨 Most toolchains do not type-check at all
🚨 This page's own TypeScript column is proof: it is run by tsx, which strips types with esbuild and never checks them. A cast that is obviously false runs without complaint.
// This file is being run by tsx, which uses esbuild to strip the types // and does NOT type-check. So the lie below runs: const value = "text" as unknown as number; console.log(typeof value); // "string" // esbuild, swc, babel and Vite all do the same thing: they DELETE the // types without reading them. Only 'tsc --noEmit' checks.
// Which means the JavaScript is the truth and the type error was // never looked for. Running a build is not evidence the types hold. const value = "text"; console.log(typeof value);
This surprises people who assume a successful build means the types passed. Every fast toolchain — esbuild, swc, Babel, Vite, Bun, and Node's own type stripping — erases types without reading them, because checking requires the whole program and stripping does not. So tsc --noEmit has to run separately, in continuous integration and ideally in a watch task, or the types are decoration. This is the single most consequential thing to know about how modern TypeScript is actually built.
The type-only subset, and why Node can run it
Node can now run a .ts file by deleting the type syntax, which works only because most TypeScript syntax genuinely erases. The three constructs from the "What Actually Emits Code" section are exactly the ones it refuses.
// Node 22.6+ can execute .ts files directly by STRIPPING types — but // only the erasable ones. These three emit code and are therefore // rejected under --experimental-strip-types (and by // erasableSyntaxOnly in tsconfig): // // enum Direction { North } ← emits an object // namespace Geometry { } ← emits an IIFE // constructor(public x: number) {} ← emits an assignment // // Everything else on this page erases cleanly and runs as-is. const count: number = 3; console.log(count);
// The stripped output keeps the source positions, replacing types // with whitespace so stack traces still point at the right column. const count = 3; console.log(count);
That divide has become a design constraint rather than a curiosity. erasableSyntaxOnly in tsconfig.json makes the compiler reject enum, namespace and parameter properties so a codebase stays runnable by any stripper, and the TypeScript team now recommends that direction for new code — a union of string literals instead of an enum, a module instead of a namespace, and an explicit field assignment instead of the constructor shorthand.
The target setting rewrites your code
Type erasure is not the only thing the compiler does. target decides which JavaScript features get downlevelled into older equivalents, and that rewriting is often larger than the erasure.
// One source file, several possible outputs. With target: "ES2015" // an async function becomes a generator driven by a state machine, // class fields become constructor assignments, and '??' becomes a // conditional. // // With target: "ES2022" — the sensible modern default — almost none // of that rewriting happens and the output resembles the input. class Counter { count = 0; // a class field: ES2022 native, ES2015 rewritten increment(): number { return ++this.count; } } const counter = new Counter(); counter.increment(); console.log(counter.increment());
// The ES2022 emission — which is to say, the same code with the // annotation removed. class Counter { count = 0; increment() { return ++this.count; } } const counter = new Counter(); counter.increment(); console.log(counter.increment());
It is worth reading your own output once, because the downlevelled forms have real consequences: a rewritten async function has a different stack trace, downlevelled class fields change property initialization order, and a helper such as __awaiter gets copied into every file unless importHelpers pulls it from tslib. Since every current runtime supports ES2022, the usual right answer is to target it and let the output stay recognizable.
Strictness is a dial, and the default is low
Everything on this page has assumed the checker is running. What it actually checks is a configuration decision, and the default without strict is permissive enough that a large codebase can be typed and unprotected.
// Without 'strict', these all compile: // // function greet(name) { } implicit any parameter // const value: string = null; null assignable to anything // class Thing { name: string; } never initialized // // 'strict' turns on eight checks at once, and two more are worth // adding by hand because they are not in it: // // noUncheckedIndexedAccess array[i] is T | undefined // exactOptionalPropertyTypes { a?: string } cannot be set to undefined function greet(name: string): string { return "hello, " + name; } console.log(greet("Ada"));
// None of it changes the output by a single character. Strictness // decides what the checker REFUSES, not what the program does. function greet(name) { return "hello, " + name; } console.log(greet("Ada"));
The practical advice is short: turn on strict, and add noUncheckedIndexedAccess, which is the flag that fixes the "indexing returns undefined" row earlier on this page and is deliberately not part of strict because it is noisy. For an existing codebase, strict can be enabled per file with // @ts-strict-style tooling or by migrating directory by directory — and the count of as any in a project is a decent proxy for how much the types are actually buying.

Thank you — anything else?