PONY λ M2 Modula-2

JavaScript.CodeCompared.To/TypeScript

An interactive executable cheatsheet comparing JavaScript and TypeScript

JavaScript (ES2025) TypeScript 6.0
Type Annotations
Variable type annotation
const greeting = "Hello, TypeScript!"; console.log(greeting);
const greeting: string = "Hello, TypeScript!"; console.log(greeting);
The : type annotation after a variable name tells TypeScript exactly what type to expect. Here it is redundant — TypeScript infers string from the literal — but annotations document intent and catch accidental mismatches when the value comes from a non-obvious source.
Primitive type annotations
const username = "Alice"; const age = 30; const active = true; console.log(username, age, active);
const username: string = "Alice"; const age: number = 30; const active: boolean = true; console.log(username, age, active);
TypeScript's three primitive types match JavaScript's: string, number, and boolean. All JavaScript numbers are 64-bit floats, so TypeScript uses a single number type for both integers and floating-point values — there is no separate int or float.
null and undefined
let middleName = null; let nickname = undefined; console.log(middleName, nickname);
let middleName: string | null = null; let nickname: string | undefined = undefined; console.log(middleName, nickname);
With strictNullChecks enabled (the TypeScript default in strict mode), null and undefined are not assignable to other types. You must explicitly include them in a union like string | null. This rule prevents the classic "Cannot read properties of null" runtime crash by making nullability visible in the type.
The any type
// No static types — everything is dynamic function processInput(value) { console.log(typeof value, value); } processInput(42); processInput("hello");
// any disables all type checking — use sparingly function processInput(value: any): void { console.log(typeof value, value); } processInput(42); processInput("hello");
The any type is a complete escape hatch from TypeScript's type system. It is useful when migrating a JavaScript codebase incrementally, but using any pervasively defeats the purpose of TypeScript. Prefer unknown when the type is genuinely not known at compile time.
The unknown type
const parsed = JSON.parse('{"score": 42}'); // No type checking — anything goes: console.log(parsed.score);
const parsed: unknown = JSON.parse('{"score": 42}'); // Must narrow before using: if (typeof parsed === 'object' && parsed !== null && 'score' in parsed) { const record = parsed as { score: number }; console.log(record.score); }
The unknown type represents a value whose type is not yet established. Unlike any, you cannot use an unknown value until you first narrow its type with a guard. This makes it the type-safe alternative to any for values from external sources like JSON.parse, fetch, or user input.
Literal types
function setDirection(direction) { console.log("Moving:", direction); } setDirection("north");
type Direction = "north" | "south" | "east" | "west"; function setDirection(direction: Direction): void { console.log("Moving:", direction); } setDirection("north"); // setDirection("up"); // ← compile error: "up" is not assignable to Direction
Literal types constrain a value to one specific string, number, or boolean. Combined with union types they create closed sets of allowed values. TypeScript reports a compile-time error if you pass any value not in the union — catching typos and invalid states before the code runs.
The never type
function fail(message) { throw new Error(message); } try { fail("Something went wrong"); } catch (error) { console.log("Caught:", error.message); }
function fail(message: string): never { throw new Error(message); } try { fail("Something went wrong"); } catch (error) { console.log("Caught:", (error as Error).message); }
The never type represents a value that can never occur. Functions that always throw or loop forever have return type never. TypeScript also uses never in exhaustiveness checks — if you forget a case in a discriminated union switch, the uncovered branch will have type never, which the compiler flags.
Type Inference
Variable inference
const score = 100; const label = "points"; const active = true; console.log(score, label, active);
const score = 100; // inferred: number const label = "points"; // inferred: string const active = true; // inferred: boolean console.log(score, label, active);
TypeScript infers types from initial values, so you rarely need to annotate simple variable declarations. Hover over a variable in your editor and TypeScript will show the inferred type. Explicit annotations add most value when the initializer is non-obvious, the type is complex, or you want to document a public API contract.
Return type inference
function double(value) { return value * 2; } console.log(double(21));
function double(value: number) { return value * 2; // return type inferred as number } console.log(double(21));
TypeScript infers function return types from the return statement. If every path returns a number, the return type is number without an explicit annotation. That said, annotating public API functions explicitly is good practice — it documents intent and catches mistakes where an early return introduces an unexpected type.
const assertions (as const)
const config = { host: "localhost", port: 3000, }; console.log(config.host, config.port);
const config = { host: "localhost", port: 3000, } as const; // config.host is inferred as "localhost" (literal), not string // config.port is inferred as 3000 (literal), not number // Every property is also readonly console.log(config.host, config.port);
The as const assertion narrows inferred types from broad primitives (string, number) to exact literal types ("localhost", 3000). It also marks every property as readonly. Use it on config objects, lookup tables, and any value that should never change after creation.
Contextual typing
const numbers = [1, 2, 3, 4, 5]; const doubled = numbers.map(number => number * 2); console.log(doubled);
const numbers: number[] = [1, 2, 3, 4, 5]; const doubled = numbers.map(number => number * 2); // TypeScript infers that number is a number from context — no annotation needed console.log(doubled);
Contextual typing means TypeScript uses the expected type of an expression to give inner function parameters their types. Inside numbers.map(), TypeScript knows number is a number because numbers is number[] — you don't need to annotate the callback parameter separately.
Interfaces & Object Types
Interface declaration
const person = { name: "Alice", age: 30 }; function greet(person) { console.log(`Hello, ${person.name}! Age: ${person.age}`); } greet(person);
interface Person { name: string; age: number; } const person: Person = { name: "Alice", age: 30 }; function greet(person: Person): void { console.log(`Hello, ${person.name}! Age: ${person.age}`); } greet(person);
An interface names and describes the shape of an object — which properties it has and what types they hold. TypeScript uses structural typing: any object with the required properties satisfies the interface, regardless of how it was created. You do not need to declare that a class "implements" the interface to satisfy it (though doing so makes the intent explicit).
Optional properties
function formatName(person) { const full = person.middle ? `${person.first} ${person.middle} ${person.last}` : `${person.first} ${person.last}`; console.log(full); } formatName({ first: "Alice", last: "Smith" }); formatName({ first: "Bob", middle: "James", last: "Jones" });
interface Name { first: string; middle?: string; // optional — may be undefined last: string; } function formatName(person: Name): void { const full = person.middle ? `${person.first} ${person.middle} ${person.last}` : `${person.first} ${person.last}`; console.log(full); } formatName({ first: "Alice", last: "Smith" }); formatName({ first: "Bob", middle: "James", last: "Jones" });
A property marked with ? is optional — its type becomes T | undefined automatically. TypeScript requires you to handle the absent case before using the value. Without the optional marker, passing an object without that property is a compile-time error.
Readonly properties
const point = { x: 10, y: 20 }; // Nothing prevents accidental mutation in plain JS: // point.x = 99; console.log(point.x, point.y);
interface Point { readonly x: number; readonly y: number; } const point: Point = { x: 10, y: 20 }; // point.x = 99; // ← compile error: cannot assign to 'x' because it is read-only console.log(point.x, point.y);
The readonly modifier prevents a property from being reassigned after the object is created. It is enforced at compile time only — the underlying JavaScript object can still be mutated via bracket notation at runtime. Use readonly to express intent and catch accidental mutations during development.
Index signatures
const scores = {}; scores["alice"] = 95; scores["bob"] = 87; console.log(scores["alice"], scores["bob"]);
interface ScoreMap { [playerName: string]: number; } const scores: ScoreMap = {}; scores["alice"] = 95; scores["bob"] = 87; // scores["carol"] = "ninety"; // ← compile error: string is not number console.log(scores["alice"], scores["bob"]);
An index signature describes the types of keys and values for an object used as a dictionary. The key name inside the brackets (playerName here) is purely for documentation — what matters is the key type (string) and the value type (number). TypeScript checks that every value in the object matches the declared value type.
Extending interfaces
const employee = { name: "Alice", age: 30, department: "Engineering", salary: 95000, }; console.log(employee.name, employee.department);
interface Person { name: string; age: number; } interface Employee extends Person { department: string; salary: number; } const employee: Employee = { name: "Alice", age: 30, department: "Engineering", salary: 95000, }; console.log(employee.name, employee.department);
An interface can extend one or more other interfaces, inheriting all their members. The extending interface must provide all the parent interface's required properties plus its own. This mirrors class inheritance in the type world but is purely structural — it adds no runtime behavior.
Interface vs type alias
// JavaScript has neither — just use objects const location = { latitude: 37.77, longitude: -122.41 }; console.log(location.latitude, location.longitude);
// Both describe the same object shape: interface Coordinates { latitude: number; longitude: number; } type CoordinatesToo = { latitude: number; longitude: number; }; const location: Coordinates = { latitude: 37.77, longitude: -122.41 }; console.log(location.latitude, location.longitude);
Interfaces and type aliases are largely interchangeable for object shapes. The key differences: interfaces can be extended with extends and merged via redeclaration; type aliases can describe unions, tuples, and primitives — not just objects. The common convention is to use interface for object shapes and type for everything else.
Arrays & Tuples
Typed arrays
const scores = [95, 87, 73, 91]; const names = ["Alice", "Bob", "Carol"]; console.log(scores[0], names[0]);
const scores: number[] = [95, 87, 73, 91]; const names: Array<string> = ["Alice", "Bob", "Carol"]; // scores.push("not a number"); // ← compile error console.log(scores[0], names[0]);
TypeScript offers two equivalent syntaxes for typed arrays: number[] (shorthand) and Array<number> (generic form). Both are idiomatic; number[] is more common. TypeScript prevents pushing a value of the wrong type or calling methods that expect a different element type.
Tuple types
// JavaScript arrays have no fixed-length guarantee const coordinate = [37.77, -122.41, "San Francisco"]; console.log(coordinate[0], coordinate[1], coordinate[2]);
// Tuples have a fixed length and per-index types const coordinate: [number, number, string] = [37.77, -122.41, "San Francisco"]; const [latitude, longitude, cityName] = coordinate; console.log(latitude, longitude, cityName);
A tuple is a fixed-length array where each index has its own distinct type. TypeScript enforces both the length and the type at each position. Destructuring a tuple with meaningful variable names makes the code self-documenting. Tuples are especially common as function return types when returning two or three related values without defining a named object type.
Readonly arrays
const colors = ["red", "green", "blue"]; // Nothing stops mutation in plain JS: colors.push("yellow"); console.log(colors);
const colors: readonly string[] = ["red", "green", "blue"]; // colors.push("yellow"); // ← compile error: push does not exist on readonly string[] console.log(colors);
The readonly modifier on an array type removes all mutating methods (push, pop, splice, etc.) from the type. The array is unchanged at runtime — this is a compile-time contract. Use it on function parameters you promise not to mutate, or on values that should be treated as immutable after creation.
Tuple return types
function minMax(numbers) { const sorted = [...numbers].sort((a, b) => a - b); return [sorted[0], sorted[sorted.length - 1]]; } const [minimum, maximum] = minMax([3, 1, 4, 1, 5, 9, 2, 6]); console.log(minimum, maximum);
function minMax(numbers: number[]): [number, number] { const sorted = [...numbers].sort((a, b) => a - b); return [sorted[0], sorted[sorted.length - 1]]; } const [minimum, maximum] = minMax([3, 1, 4, 1, 5, 9, 2, 6]); console.log(minimum, maximum);
Returning a typed tuple from a function lets callers destructure the result with known types for each element. Without the explicit tuple return type, TypeScript infers (number | undefined)[] — a less precise type that requires more narrowing at the call site.
Functions
Parameter and return types
function add(first, second) { return first + second; } console.log(add(10, 32));
function add(first: number, second: number): number { return first + second; } console.log(add(10, 32));
TypeScript requires type annotations on function parameters (they are not inferred from call sites). The return type can be inferred from the implementation, but annotating it explicitly documents the contract and catches bugs where a code path returns the wrong type or inadvertently returns undefined.
Optional parameters
function greet(name, greeting) { const message = greeting || "Hello"; console.log(`${message}, ${name}!`); } greet("Alice"); greet("Bob", "Hi");
function greet(name: string, greeting?: string): void { const message = greeting ?? "Hello"; console.log(`${message}, ${name}!`); } greet("Alice"); greet("Bob", "Hi");
Marking a parameter with ? makes it optional — callers may omit it. Inside the function the type becomes string | undefined, so TypeScript requires handling both cases before using the value. Optional parameters must come after all required ones.
Default parameters
function power(base, exponent = 2) { return Math.pow(base, exponent); } console.log(power(3)); console.log(power(3, 3));
function power(base: number, exponent: number = 2): number { return Math.pow(base, exponent); } console.log(power(3)); console.log(power(3, 3));
Default parameter values work identically in TypeScript and JavaScript. TypeScript infers the parameter type from the default value when no annotation is given, but being explicit is clearer. A parameter with a default value is automatically optional at the call site.
Rest parameters
function sum(...values) { return values.reduce((total, value) => total + value, 0); } console.log(sum(1, 2, 3, 4, 5));
function sum(...values: number[]): number { return values.reduce((total, value) => total + value, 0); } console.log(sum(1, 2, 3, 4, 5));
Rest parameters collect all trailing arguments into a typed array. TypeScript enforces that every extra argument matches the element type — passing a string to this sum function would be a compile error. A rest parameter must come last and there can only be one per function.
Function types
function applyTwice(transform, value) { return transform(transform(value)); } console.log(applyTwice(x => x * 2, 3));
function applyTwice(transform: (value: number) => number, value: number): number { return transform(transform(value)); } console.log(applyTwice(x => x * 2, 3));
Function types describe the parameter types and return type of a callable value. The syntax (paramName: Type) => ReturnType describes a function. TypeScript checks that every function you pass as an argument matches the declared signature — wrong arity or wrong parameter types are caught at compile time.
void return type
function logMessage(message) { console.log("[LOG]", message); } logMessage("Server started");
function logMessage(message: string): void { console.log("[LOG]", message); } logMessage("Server started");
The void return type marks functions that are called for side effects and do not return a meaningful value. It differs subtly from undefined: a void function can technically return undefined, but callers should not rely on the return value.
Function overloads
function formatDate(value) { if (typeof value === "string") return new Date(value).toLocaleDateString(); return value.toLocaleDateString(); } console.log(formatDate("2025-01-15")); console.log(formatDate(new Date(2025, 0, 15)));
function formatDate(value: string): string; function formatDate(value: Date): string; function formatDate(value: string | Date): string { if (typeof value === "string") return new Date(value).toLocaleDateString(); return value.toLocaleDateString(); } console.log(formatDate("2025-01-15")); console.log(formatDate(new Date(2025, 0, 15)));
Function overloads let you declare multiple precise call signatures for the same function. Callers see exactly which argument types are accepted and what each produces. The final (implementation) signature covers all cases internally but is not visible to callers — only the overload signatures above it are.
Union & Intersection Types
Union types
function stringify(value) { if (typeof value === "number") return String(value); return value; } console.log(stringify(42)); console.log(stringify("hello"));
function stringify(value: string | number): string { if (typeof value === "number") return String(value); return value; } console.log(stringify(42)); console.log(stringify("hello"));
A union type A | B means "either A or B." You cannot use members of one branch without first narrowing to it — TypeScript requires a typeof check, instanceof check, or other discriminant before granting access to type-specific properties or methods.
Intersection types
const developer = { name: "Alice", role: "developer", department: "Engineering", salary: 95000, }; console.log(developer.name, developer.role);
type Person = { name: string; role: string }; type Employee = { department: string; salary: number }; type Developer = Person & Employee; const developer: Developer = { name: "Alice", role: "developer", department: "Engineering", salary: 95000, }; console.log(developer.name, developer.role);
An intersection type A & B means "both A and B simultaneously" — the result has all properties of both types. It is the type-level equivalent of merging multiple objects into one. Intersections are most useful for composing modular type definitions without inheritance.
Discriminated unions
function describeShape(shape) { if (shape.kind === "circle") return `Circle r=${shape.radius}`; if (shape.kind === "rectangle") return `Rect ${shape.width}×${shape.height}`; return "Unknown"; } console.log(describeShape({ kind: "circle", radius: 5 })); console.log(describeShape({ kind: "rectangle", width: 4, height: 6 }));
type Circle = { kind: "circle"; radius: number }; type Rectangle = { kind: "rectangle"; width: number; height: number }; type Shape = Circle | Rectangle; function describeShape(shape: Shape): string { if (shape.kind === "circle") return `Circle r=${shape.radius}`; return `Rect ${shape.width}×${shape.height}`; // narrowed to Rectangle } console.log(describeShape({ kind: "circle", radius: 5 })); console.log(describeShape({ kind: "rectangle", width: 4, height: 6 }));
A discriminated union is a union of object types where each member has a common "tag" property with a unique literal type. TypeScript uses the tag to narrow the type in each branch — after checking shape.kind === "circle", TypeScript knows the shape is a Circle and allows access to radius without a cast.
Nullish coalescing & optional chaining
function getDisplayName(user) { return user?.displayName ?? user?.email ?? "Anonymous"; } console.log(getDisplayName({ displayName: "Alice" })); console.log(getDisplayName({ email: "bob@example.com" })); console.log(getDisplayName(null));
interface User { displayName?: string; email?: string; } function getDisplayName(user: User | null): string { return user?.displayName ?? user?.email ?? "Anonymous"; } console.log(getDisplayName({ displayName: "Alice" })); console.log(getDisplayName({ email: "bob@example.com" })); console.log(getDisplayName(null));
TypeScript tracks nullability explicitly: User | null means either a User object or null. The optional chaining operator ?. short-circuits to undefined when the receiver is null or undefined, and TypeScript understands this narrowing — no cast required.
Type Aliases
Type aliases
function distanceBetween(pointA, pointB) { return Math.sqrt( Math.pow(pointB.x - pointA.x, 2) + Math.pow(pointB.y - pointA.y, 2) ); } console.log(distanceBetween({ x: 0, y: 0 }, { x: 3, y: 4 }));
type Point = { x: number; y: number }; function distanceBetween(pointA: Point, pointB: Point): number { return Math.sqrt( Math.pow(pointB.x - pointA.x, 2) + Math.pow(pointB.y - pointA.y, 2) ); } console.log(distanceBetween({ x: 0, y: 0 }, { x: 3, y: 4 }));
A type alias creates a name for any TypeScript type — object shapes, unions, primitives, tuples, or complex combinations. Unlike interfaces, type aliases cannot be extended with extends or reopened via declaration merging, but they can express any type that an interface cannot (such as union types or mapped types).
Recursive types
function countKeys(value) { if (typeof value !== "object" || value === null) return 0; return Object.keys(value).length + Object.values(value).reduce((total, child) => total + countKeys(child), 0); } console.log(countKeys({ a: 1, b: { c: 2, d: { e: 3 } } }));
type JsonValue = | string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; function countKeys(value: JsonValue): number { if (typeof value !== "object" || value === null || Array.isArray(value)) return 0; return Object.keys(value).length + Object.values(value).reduce((total, child) => total + countKeys(child), 0); } console.log(countKeys({ a: 1, b: { c: 2, d: { e: 3 } } }));
Type aliases can reference themselves to describe recursive data structures. The JsonValue type captures the full JSON grammar: primitives, arrays of JsonValue, and objects whose values are JsonValue — all in one clean recursive definition.
Template literal types
const events = ["click", "focus", "blur"]; const handlerNames = events.map(e => `on${e[0].toUpperCase() + e.slice(1)}`); console.log(handlerNames.join(", "));
type EventName = "click" | "focus" | "blur"; type HandlerName = `on${Capitalize<EventName>}`; // HandlerName resolves to: "onClick" | "onFocus" | "onBlur" const handlers: Record<HandlerName, (() => void) | null> = { onClick: () => console.log("clicked"), onFocus: null, onBlur: null, }; handlers.onClick?.(); console.log("Handlers:", Object.keys(handlers).join(", "));
Template literal types use the same backtick syntax as JavaScript template literals, but at the type level. TypeScript distributes them across union members — `on${EventName}` produces the union "onClick" | "onFocus" | "onBlur". Built-in helpers like Capitalize, Uppercase, and Lowercase work inside template literal types.
Generics
Generic functions
function firstElement(items) { return items[0]; } console.log(firstElement([10, 20, 30])); console.log(firstElement(["a", "b", "c"]));
function firstElement<Item>(items: Item[]): Item | undefined { return items[0]; } console.log(firstElement([10, 20, 30])); // infers Item = number console.log(firstElement(["a", "b", "c"])); // infers Item = string
A generic function uses a type parameter (the <Item> between the function name and parameter list) to relate input and output types. TypeScript infers the type argument from the actual argument — calling with a number[] infers Item = number, so the return type is number | undefined.
Generic classes
class Stack { #items = []; push(item) { this.#items.push(item); } pop() { return this.#items.pop(); } peek() { return this.#items[this.#items.length - 1]; } get size() { return this.#items.length; } } const stack = new Stack(); stack.push(1); stack.push(2); console.log(stack.peek(), stack.size);
class Stack<Item> { #items: Item[] = []; push(item: Item): void { this.#items.push(item); } pop(): Item | undefined { return this.#items.pop(); } peek(): Item | undefined { return this.#items[this.#items.length - 1]; } get size(): number { return this.#items.length; } } const stack = new Stack<number>(); stack.push(1); stack.push(2); // stack.push("text"); // ← compile error: string is not number console.log(stack.peek(), stack.size);
Generic classes carry a type parameter through all their methods, ensuring consistent behavior with whatever type they are parameterized with. A Stack<number> only accepts and produces number values — pushing a string is a compile error. TypeScript infers the type argument when one is not supplied explicitly.
Generic constraints
function getProperty(obj, key) { return obj[key]; } console.log(getProperty({ name: "Alice", age: 30 }, "name"));
function getProperty<Obj, Key extends keyof Obj>(obj: Obj, key: Key): Obj[Key] { return obj[key]; } // Return type is string when Key = "name", number when Key = "age" console.log(getProperty({ name: "Alice", age: 30 }, "name"));
The extends keyword in a type parameter list adds a constraint — here Key extends keyof Obj ensures the key is always a real property of the object. Passing a non-existent key is a compile error. The return type Obj[Key] (indexed access type) automatically reflects the type of that specific property.
keyof operator
const serverConfig = { host: "localhost", port: 3000, debug: true }; console.log(Object.keys(serverConfig));
interface Config { host: string; port: number; debug: boolean; } type ConfigKey = keyof Config; // "host" | "port" | "debug" function getConfigValue(config: Config, key: ConfigKey): string | number | boolean { return config[key]; } const serverConfig: Config = { host: "localhost", port: 3000, debug: true }; console.log(getConfigValue(serverConfig, "port"));
The keyof operator produces a union of a type's property names as string literals. It gives compile-time safety to functions that accept a key as a parameter — the key must be one of the type's actual properties, and TypeScript knows the return type precisely for each possible key.
Conditional types
// JavaScript has no conditional types — this is a TypeScript-only concept. // The equivalent runtime check: function unwrapPromise(value) { return value instanceof Promise ? value.then(v => v) : Promise.resolve(value); } unwrapPromise(Promise.resolve("hello")).then(v => console.log(v));
type Unwrap<PromiseType> = PromiseType extends Promise<infer Inner> ? Inner : PromiseType; // Unwrap<Promise<string>> → string // Unwrap<string> → string (unchanged) type Result = Unwrap<Promise<string>>; // = string const value: Result = "hello"; console.log(value);
Conditional types use the syntax T extends U ? X : Y — "if T is assignable to U, resolve to X; otherwise to Y." The infer keyword introduces a type variable that TypeScript fills in by matching a pattern. Conditional types are the foundation of built-in utility types like Awaited, ReturnType, and Parameters.
Enums
Numeric enums
const Direction = { North: 0, South: 1, East: 2, West: 3, }; const heading = Direction.North; console.log(heading, heading === 0);
enum Direction { North, // 0 South, // 1 East, // 2 West, // 3 } const heading: Direction = Direction.North; console.log(heading, heading === Direction.North);
TypeScript enums generate real JavaScript objects at runtime (not just types). Numeric enums start at 0 and auto-increment. They also support reverse mapping: Direction[0] evaluates to "North". This reverse mapping is unique to numeric enums and absent from string enums.
String enums
const Status = { Pending: "PENDING", Active: "ACTIVE", Inactive: "INACTIVE", }; const currentStatus = Status.Active; console.log(currentStatus);
enum Status { Pending = "PENDING", Active = "ACTIVE", Inactive = "INACTIVE", } const currentStatus: Status = Status.Active; console.log(currentStatus);
String enums require explicit values for every member. They are generally preferred over numeric enums for serialized data (API payloads, logs) because the values are readable strings rather than opaque numbers. String enums do not support reverse mapping.
Union types as enum alternatives
function setFontSize(size) { if (!["small", "medium", "large"].includes(size)) { throw new Error("Invalid size: " + size); } console.log("Font size set to:", size); } setFontSize("medium");
type FontSize = "small" | "medium" | "large"; function setFontSize(size: FontSize): void { console.log("Font size set to:", size); } setFontSize("medium"); // setFontSize("huge"); // ← compile error: not assignable to FontSize
Many TypeScript developers prefer union types over enums for string-like choices. A union type is purely a compile-time construct with zero runtime overhead, while an enum generates a full JavaScript object. Unions are also more ergonomic — you write "medium" directly instead of FontSize.Medium.
Classes
Access modifiers
class BankAccount { #balance = 0; constructor(initialBalance) { this.#balance = initialBalance; } deposit(amount) { this.#balance += amount; } get balance() { return this.#balance; } } const account = new BankAccount(100); account.deposit(50); console.log(account.balance);
class BankAccount { private balance: number; constructor(initialBalance: number) { this.balance = initialBalance; } deposit(amount: number): void { this.balance += amount; } getBalance(): number { return this.balance; } } const account = new BankAccount(100); account.deposit(50); // account.balance; // ← compile error: 'balance' is private console.log(account.getBalance());
TypeScript adds private, protected, and public modifiers. These are compile-time restrictions only — they do not prevent access at runtime the way JavaScript's native #field private syntax does. For true runtime privacy use #field; use TypeScript's private when you want the constraint only in the type system.
Parameter properties
class Point { constructor(x, y) { this.x = x; this.y = y; } toString() { return `(${this.x}, ${this.y})`; } } console.log(new Point(3, 4).toString());
class Point { constructor( public readonly x: number, public readonly y: number, ) {} toString(): string { return `(${this.x}, ${this.y})`; } } console.log(new Point(3, 4).toString());
Parameter properties are a TypeScript shorthand that declares and assigns a class field in one step. Adding a modifier (public, private, protected, or readonly) to a constructor parameter both declares the property on the class and assigns the argument to it, eliminating the boilerplate of this.x = x.
Abstract classes
class Animal { constructor(name) { this.name = name; } speak() { throw new Error("speak() must be implemented"); } describe() { return `${this.name} says: ${this.speak()}`; } } class Dog extends Animal { speak() { return "Woof!"; } } console.log(new Dog("Rex").describe());
abstract class Animal { constructor(protected name: string) {} abstract speak(): string; // subclasses must implement this describe(): string { return `${this.name} says: ${this.speak()}`; } } class Dog extends Animal { speak(): string { return "Woof!"; } } // new Animal("thing"); // ← compile error: cannot instantiate abstract class console.log(new Dog("Rex").describe());
An abstract class cannot be instantiated directly — it serves as a blueprint that subclasses must complete. Abstract methods have no body; every concrete subclass must implement them, and TypeScript reports an error at the class definition if any are missing. This enforces the contract at compile time rather than relying on a runtime throw.
Implementing interfaces
class Rectangle { constructor(width, height) { this.width = width; this.height = height; } area() { return this.width * this.height; } perimeter() { return 2 * (this.width + this.height); } } const rect = new Rectangle(4, 6); console.log(rect.area(), rect.perimeter());
interface Shape { area(): number; perimeter(): number; } class Rectangle implements Shape { constructor( private width: number, private height: number, ) {} area(): number { return this.width * this.height; } perimeter(): number { return 2 * (this.width + this.height); } } const rect = new Rectangle(4, 6); console.log(rect.area(), rect.perimeter());
The implements keyword makes the class's conformance to an interface explicit and compiler-checked. If the class is missing any required method or property, TypeScript reports a clear error. Structural typing means the class satisfies the interface even without implements, but the explicit declaration documents intent and catches missing methods early.
Type Narrowing
typeof narrowing
function formatValue(value) { if (typeof value === "string") return value.toUpperCase(); if (typeof value === "number") return value.toFixed(2); return String(value); } console.log(formatValue("hello")); console.log(formatValue(3.14159));
function formatValue(value: string | number | boolean): string { if (typeof value === "string") return value.toUpperCase(); // narrowed to string if (typeof value === "number") return value.toFixed(2); // narrowed to number return String(value); // narrowed to boolean } console.log(formatValue("hello")); console.log(formatValue(3.14159));
TypeScript understands typeof checks and narrows the variable type within each branch. After typeof value === "string", TypeScript knows value is a string and allows string methods. Methods from other union branches are unavailable until the matching guard is entered.
instanceof narrowing
function getTimestamp(value) { if (value instanceof Date) return value.getTime(); return new Date(value).getTime(); } console.log(getTimestamp(new Date("2025-01-01"))); console.log(getTimestamp("2025-06-15"));
function getTimestamp(value: Date | string): number { if (value instanceof Date) return value.getTime(); // narrowed to Date return new Date(value).getTime(); // narrowed to string } console.log(getTimestamp(new Date("2025-01-01"))); console.log(getTimestamp("2025-06-15"));
The instanceof operator narrows a value to the class it was constructed from. TypeScript tracks this narrowing and grants access to class-specific methods and properties within that branch. instanceof works for any class, including built-in ones like Date, Map, and Error.
in operator narrowing
function describeAnimal(animal) { if ("flySpeed" in animal) console.log("Can fly at", animal.flySpeed, "mph"); else console.log("Cannot fly, runs at", animal.runSpeed, "mph"); } describeAnimal({ flySpeed: 60 }); describeAnimal({ runSpeed: 30 });
type Bird = { flySpeed: number }; type Dog = { runSpeed: number }; function describeAnimal(animal: Bird | Dog): void { if ("flySpeed" in animal) { console.log("Can fly at", animal.flySpeed, "mph"); // narrowed to Bird } else { console.log("Cannot fly, runs at", animal.runSpeed, "mph"); // narrowed to Dog } } describeAnimal({ flySpeed: 60 }); describeAnimal({ runSpeed: 30 });
The in operator checks whether a property exists on an object. TypeScript uses it as a type guard: when "flySpeed" in animal is true, TypeScript narrows animal to the union members that declare that property. This is useful for discriminating between types that share no common tag property.
Type predicates
function isString(value) { return typeof value === "string"; } const mixed = [1, "two", 3, "four", 5]; const strings = mixed.filter(isString); console.log(strings);
function isString(value: unknown): value is string { return typeof value === "string"; } const mixed: (number | string)[] = [1, "two", 3, "four", 5]; const strings: string[] = mixed.filter(isString); console.log(strings);
A type predicate (value is string) is a special return type that tells TypeScript: "if this function returns true, the argument is now narrowed to this type." Without the predicate, filter(isString) returns (string | number)[]. With it, TypeScript knows the output array contains only string values.
Utility Types
Partial<T>
function updateSettings(current, updates) { return { ...current, ...updates }; } const settings = { theme: "dark", fontSize: 16, notifications: true }; const updated = updateSettings(settings, { fontSize: 18 }); console.log(JSON.stringify(updated));
interface Settings { theme: string; fontSize: number; notifications: boolean; } function updateSettings(current: Settings, updates: Partial<Settings>): Settings { return { ...current, ...updates }; } const settings: Settings = { theme: "dark", fontSize: 16, notifications: true }; const updated = updateSettings(settings, { fontSize: 18 }); console.log(JSON.stringify(updated));
Partial<T> makes every property of T optional — ideal for update or patch functions where only some fields change. Without Partial you would have to duplicate the interface with every property marked ?.
Readonly<T>
const DEFAULT_CONFIG = { retries: 3, timeout: 5000, }; // Nothing enforces immutability in plain JavaScript console.log(DEFAULT_CONFIG.retries);
interface NetworkConfig { retries: number; timeout: number; } const DEFAULT_CONFIG: Readonly<NetworkConfig> = { retries: 3, timeout: 5000, }; // DEFAULT_CONFIG.retries = 5; // ← compile error: cannot assign to 'retries' (read-only) console.log(DEFAULT_CONFIG.retries);
Readonly<T> produces a version of T where every property is readonly. Use it on configuration objects and constants to prevent accidental mutation. Like all readonly modifiers, it is enforced at compile time only.
Record<K, V>
const scores = {}; for (const player of ["Alice", "Bob", "Carol"]) scores[player] = 0; console.log(JSON.stringify(scores));
type PlayerName = "Alice" | "Bob" | "Carol"; const scores: Record<PlayerName, number> = { Alice: 0, Bob: 0, Carol: 0, }; // scores["Dave"] = 0; // ← compile error: "Dave" is not a valid key console.log(JSON.stringify(scores));
Record<K, V> creates an object type whose keys are type K and values are type V. When K is a union of string literals, TypeScript ensures the object has exactly those keys — missing or extra keys are both compile errors. Record<string, V> is the typed equivalent of a plain dictionary object.
Pick<T, K> and Omit<T, K>
const user = { id: 1, name: "Alice", email: "a@example.com", password: "secret" }; const publicProfile = { id: user.id, name: user.name }; console.log(JSON.stringify(publicProfile));
interface User { id: number; name: string; email: string; password: string; } type PublicProfile = Omit<User, "password" | "email">; // Equivalent: Pick<User, "id" | "name"> const user: User = { id: 1, name: "Alice", email: "a@example.com", password: "secret" }; const publicProfile: PublicProfile = { id: user.id, name: user.name }; console.log(JSON.stringify(publicProfile));
Pick<T, K> selects a subset of properties from T; Omit<T, K> removes the named properties and keeps the rest. They are complementary: use Pick when you know which properties to include, and Omit when it is clearer to name what to exclude — such as removing sensitive fields from a user type.
ReturnType<F> and Parameters<F>
function createUser(name, role) { return { name, role, createdAt: new Date().toISOString() }; } const user = createUser("Alice", "admin"); console.log(user.name, user.role);
function createUser(name: string, role: "admin" | "user") { return { name, role, createdAt: new Date().toISOString() }; } type NewUser = ReturnType<typeof createUser>; // { name: string; role: "admin" | "user"; createdAt: string } type UserArgs = Parameters<typeof createUser>; // [name: string, role: "admin" | "user"] const user: NewUser = createUser("Alice", "admin"); console.log(user.name, user.role);
ReturnType<F> extracts the return type of a function; Parameters<F> extracts its parameter types as a tuple. These utilities derive types from existing functions without duplicating declarations — the derived types stay in sync automatically whenever the function signature changes.
NonNullable<T>
function compact(items) { return items.filter(item => item != null); } console.log(compact([1, null, 2, undefined, 3]));
function compact<Item>(items: (Item | null | undefined)[]): NonNullable<Item>[] { return items.filter((item): item is NonNullable<Item> => item != null); } const result = compact([1, null, 2, undefined, 3]); // result is number[], not (number | null | undefined)[] console.log(result);
NonNullable<T> removes null and undefined from a type. Combined with a type predicate in the filter callback, it tells TypeScript that the resulting array contains no nullish values. Without the predicate, filter would preserve the nullish members in the element type.
Type Assertions
as — type assertion
// JavaScript trusts you — no type assertions needed const element = document.getElementById("app"); // element.textContent = "Hello"; // could throw if element is null
// TypeScript needs help when inference is too broad const element = document.getElementById("app") as HTMLElement; element.textContent = "Hello"; // Without the assertion, TypeScript sees HTMLElement | null // and rejects the .textContent assignment
The as Type assertion overrides TypeScript's inference. Use it when you have information the type checker does not — such as knowing that a particular element will always be present. Assertions do not perform any runtime conversion; they are purely a compile-time signal and carry no safety guarantee.
Non-null assertion (!)
const lookup = new Map([["answer", 42]]); const value = lookup.get("answer"); console.log(value * 2); // risky if key absent
const lookup = new Map<string, number>([["answer", 42]]); const value = lookup.get("answer"); // type: number | undefined // The ! asserts the value is definitely present: const doubled = lookup.get("answer")! * 2; // type: number console.log(doubled);
The non-null assertion operator ! tells TypeScript that a value is definitely not null or undefined, removing those from its type. If the value is actually absent at runtime, the code will throw or produce NaN. Use it sparingly — prefer explicit narrowing or early returns when the absent case is possible.
satisfies operator
const palette = { red: [255, 0, 0], green: [0, 255, 0], blue: "#0000ff", }; console.log(palette.red[0], palette.blue.toUpperCase());
type ColorMap = Record<string, string | number[]>; const palette = { red: [255, 0, 0], green: [0, 255, 0], blue: "#0000ff", } satisfies ColorMap; // palette.red is number[] (specific) — not widened to string | number[] // palette.blue is string (specific) — not widened to string | number[] console.log(palette.red[0], palette.blue.toUpperCase());
The satisfies operator (TypeScript 4.9+) validates that a value conforms to a type without widening the inferred type. Unlike : ColorMap (which makes every property's type the broad string | number[]), satisfies keeps the specific types while still checking conformance — giving you both safety and precision.
Error Handling
Typed catch with unknown
try { JSON.parse("{invalid}"); } catch (error) { console.log("Caught:", error.message); // no type safety on error }
try { JSON.parse("{invalid}"); } catch (error: unknown) { if (error instanceof Error) { console.log("Caught:", error.message); // narrowed to Error } else { console.log("Caught non-Error:", String(error)); } }
In TypeScript strict mode, caught values have type unknown because anything can be thrown — not just Error instances. You must narrow the type before accessing properties like .message. The instanceof Error check is the standard idiom for narrowing a caught value to the most common error shape.
Result pattern with union types
function divide(numerator, denominator) { if (denominator === 0) return { ok: false, error: "Division by zero" }; return { ok: true, value: numerator / denominator }; } const result = divide(10, 2); if (result.ok) console.log(result.value);
type Success<Value> = { ok: true; value: Value }; type Failure = { ok: false; error: string }; type Result<Value> = Success<Value> | Failure; function divide(numerator: number, denominator: number): Result<number> { if (denominator === 0) return { ok: false, error: "Division by zero" }; return { ok: true, value: numerator / denominator }; } const result = divide(10, 2); if (result.ok) { console.log(result.value); // TypeScript knows value exists here } else { console.log("Error:", result.error); }
The result pattern replaces thrown exceptions with explicit union return types. Callers are forced to check result.ok before accessing the value — TypeScript narrows the type in each branch. This makes error handling visible in the type signature and eliminates surprise thrown values, which is especially valuable when the type system can verify exhaustive handling.
Custom typed errors
class ValidationError extends Error { constructor(field, message) { super(message); this.name = "ValidationError"; this.field = field; } } try { throw new ValidationError("email", "Invalid email format"); } catch (error) { if (error instanceof ValidationError) { console.log(`Field '${error.field}': ${error.message}`); } }
class ValidationError extends Error { constructor( public readonly field: string, message: string, ) { super(message); this.name = "ValidationError"; } } try { throw new ValidationError("email", "Invalid email format"); } catch (error: unknown) { if (error instanceof ValidationError) { console.log(`Field '${error.field}': ${error.message}`); } }
Custom error classes typed with TypeScript give you property safety on error objects. The public readonly field parameter property declares and assigns the field in one step. After an instanceof ValidationError check in a catch block, TypeScript narrows the error type and allows access to .field without a cast.