TypeScript — Types & Annotations
TypeScript's type system is its core feature. Types describe the shape of your data — what values a variable can hold, what arguments a function accepts, and what it returns. TypeScript uses these types to catch errors at compile time, provide autocompletion, and document your code.
Unlike dynamically typed languages, TypeScript types are checked before code runs. The compiler (or your IDE) enforces type constraints, catching bugs like accessing undefined properties, passing wrong argument types, or misusing return values.
note
TypeScript has several primitive types corresponding to JavaScript's basic value types. Each has distinct behavior and use cases.
| 1 | // string — text values |
| 2 | let name: string = "Alice"; |
| 3 | let greeting: string = `Hello, ${name}`; |
| 4 | |
| 5 | // number — all numeric values (integers and floats, no distinction) |
| 6 | let age: number = 30; |
| 7 | let pi: number = 3.14159; |
| 8 | let hex: number = 0xff; |
| 9 | let billion: number = 1_000_000_000; // underscore separators |
| 10 | |
| 11 | // boolean — true/false |
| 12 | let active: boolean = true; |
| 13 | let deleted: boolean = false; |
| 14 | |
| 15 | // null and undefined |
| 16 | let nothing: null = null; |
| 17 | let notDefined: undefined = undefined; |
| 18 | |
| 19 | // bigint — arbitrary-precision integers (ES2020+) |
| 20 | let big: bigint = 9007199254740991n; |
| 21 | |
| 22 | // symbol — unique identifiers |
| 23 | let id: symbol = Symbol("id"); |
| Type | Example | Notes |
|---|---|---|
| string | "hello", `template` | Unicode strings |
| number | 42, 3.14, NaN, Infinity | All numbers are 64-bit floats |
| boolean | true, false | Logical values |
| null | null | Intentional absence of value |
| undefined | undefined | Uninitialized variable |
| void | return value of functions with no return | Cannot be assigned a value |
| never | function that never returns | For unreachable code |
| any | escape hatch | Opts out of type checking — avoid |
| unknown | type-safe any | Must narrow before use |
| 1 | // void — no return value |
| 2 | function log(message: string): void { |
| 3 | console.log(message); |
| 4 | } |
| 5 | |
| 6 | // never — function never returns (throws, infinite loop, or type guard) |
| 7 | function throwError(msg: string): never { |
| 8 | throw new Error(msg); |
| 9 | } |
| 10 | |
| 11 | function infiniteLoop(): never { |
| 12 | while (true) {} |
| 13 | } |
| 14 | |
| 15 | // any — opts out of type checking (avoid!) |
| 16 | let anything: any = 42; |
| 17 | anything = "now a string"; |
| 18 | anything.does.not.exist; // no error — but will crash at runtime |
| 19 | |
| 20 | // unknown — type-safe alternative to any |
| 21 | let value: unknown = 42; |
| 22 | // value.toUpperCase(); // Error: cannot use unknown directly |
| 23 | if (typeof value === "string") { |
| 24 | value.toUpperCase(); // OK after narrowing |
warning
Arrays hold ordered collections of the same type. Tuples are fixed-length arrays where each position has a specific type.
| 1 | // Arrays — two syntaxes (both equivalent) |
| 2 | let numbers: number[] = [1, 2, 3]; |
| 3 | let names: Array<string> = ["Alice", "Bob"]; |
| 4 | |
| 5 | // Read-only arrays |
| 6 | let readonly_nums: readonly number[] = [1, 2, 3]; |
| 7 | // readonly_nums.push(4); // Error: Property 'push' does not exist |
| 8 | |
| 9 | // Tuples — fixed-length, typed arrays |
| 10 | let person: [string, number] = ["Alice", 30]; |
| 11 | // person = [30, "Alice"]; // Error: wrong order |
| 12 | |
| 13 | // Named tuple elements (documentation only, not enforced at runtime) |
| 14 | let point: [x: number, y: number, z: number] = [1, 2, 3]; |
| 15 | |
| 16 | // Tuples with optional elements |
| 17 | let record: [string, number?] = ["Alice"]; // OK |
| 18 | let record2: [string, number?] = ["Alice", 30]; // OK |
| 19 | |
| 20 | // Rest elements in tuples |
| 21 | let rest: [string, ...number[]] = ["Alice", 1, 2, 3]; |
| 22 | |
| 23 | // Destructuring tuples |
| 24 | let [firstName, age] = person; |
| 25 | console.log(firstName); // Alice |
| 26 | console.log(age); // 30 |
info
Object types describe the shape of objects — what properties they have and what types those properties are. TypeScript uses structural typing, so any object with the matching shape satisfies the type.
| 1 | // Inline object type |
| 2 | let user: { name: string; age: number; email?: string } = { |
| 3 | name: "Alice", |
| 4 | age: 30, |
| 5 | }; |
| 6 | |
| 7 | // Index signatures — dynamic keys |
| 8 | let scores: { [subject: string]: number } = { |
| 9 | math: 95, |
| 10 | science: 88, |
| 11 | }; |
| 12 | |
| 13 | // Record utility type (cleaner alternative) |
| 14 | let counts: Record<string, number> = { |
| 15 | views: 100, |
| 16 | likes: 25, |
| 17 | }; |
| 18 | |
| 19 | // Nested objects |
| 20 | let config: { |
| 21 | db: { host: string; port: number }; |
| 22 | debug: boolean; |
| 23 | } = { |
| 24 | db: { host: "localhost", port: 5432 }, |
| 25 | debug: true, |
| 26 | }; |
| 27 | |
| 28 | // Readonly properties |
| 29 | let immutable: { readonly id: number; name: string } = { |
| 30 | id: 1, |
| 31 | name: "Alice", |
| 32 | }; |
| 33 | // immutable.id = 2; // Error: Cannot assign to 'id' |
TypeScript can usually figure out types automatically (inference). You only need explicit annotations when the compiler can't infer or infers something too broad. The general rule: annotate when it helps, skip when it's obvious.
| 1 | // Inference — TypeScript figures it out |
| 2 | let x = 42; // inferred: number |
| 3 | let s = "hello"; // inferred: string |
| 4 | let arr = [1, 2, 3]; // inferred: number[] |
| 5 | let obj = { a: 1, b: 2 }; // inferred: { a: number; b: number } |
| 6 | |
| 7 | // Inference from context |
| 8 | const nums = [1, 2, 3].map(n => n * 2); // inferred: number[] |
| 9 | |
| 10 | // Annotations needed when: |
| 11 | // 1. Empty initialization |
| 12 | let result: string; |
| 13 | result = getFromAPI(); // compiler needs to know the type |
| 14 | |
| 15 | // 2. Function return types (recommended for public APIs) |
| 16 | function processData(input: string[]): { count: number; items: string[] } { |
| 17 | return { count: input.length, items: input }; |
| 18 | } |
| 19 | |
| 20 | // 3. Complex types that benefit from documentation |
| 21 | type Config = { |
| 22 | host: string; |
| 23 | port: number; |
| 24 | ssl: boolean; |
| 25 | }; |
| 26 | |
| 27 | function createServer(config: Config): void { |
| 28 | // ... |
| 29 | } |
| 30 | |
| 31 | // 4. When inference is too broad |
| 32 | let data = JSON.parse("{}"); // inferred: any — annotate instead |
| 33 | let typed: Record<string, unknown> = JSON.parse("{}"); |
| 34 | |
| 35 | // Best practice: annotate function signatures, skip variable types when obvious |
| 36 | function greet(name: string): string { // annotate params + return |
| 37 | return `Hello, ${name}`; // skip — inference is fine |
| 38 | } |
best practice
Union types say "this can be one of several types." Intersection types say "this has all of these types combined." They are fundamental building blocks for composing types.
| 1 | // Union — value can be one of multiple types |
| 2 | let id: string | number; |
| 3 | id = "abc"; // OK |
| 4 | id = 123; // OK |
| 5 | // id = true; // Error |
| 6 | |
| 7 | // Union with null/undefined (nullable) |
| 8 | let name: string | null = null; |
| 9 | name = "Alice"; // OK |
| 10 | name = null; // OK |
| 11 | |
| 12 | // Function with union parameter |
| 13 | function formatId(id: string | number): string { |
| 14 | if (typeof id === "string") { |
| 15 | return id.toUpperCase(); // TS knows: string |
| 16 | } |
| 17 | return id.toString(); // TS knows: number |
| 18 | } |
| 19 | |
| 20 | // Intersection — combines all types into one |
| 21 | type HasName = { name: string }; |
| 22 | type HasAge = { age: number }; |
| 23 | type Person = HasName & HasAge; |
| 24 | |
| 25 | let person: Person = { |
| 26 | name: "Alice", // from HasName |
| 27 | age: 30, // from HasAge |
| 28 | }; |
| 29 | |
| 30 | // Intersection of interfaces |
| 31 | interface Logger { |
| 32 | log(message: string): void; |
| 33 | } |
| 34 | |
| 35 | interface Formatter { |
| 36 | format(data: unknown): string; |
| 37 | } |
| 38 | |
| 39 | type LogFormatter = Logger & Formatter; |
| 40 | |
| 41 | // Union vs Intersection decision: |
| 42 | // Union: "A or B" — use | (e.g., string | number) |
| 43 | // Intersection: "A and B" — use & (e.g., HasName & HasAge) |
info
Type assertions tell TypeScript "trust me, I know the type." They don't change the runtime value — they only change how the compiler treats it. Use them sparingly; they bypass type safety.
| 1 | // as syntax (preferred) |
| 2 | let value: unknown = "hello"; |
| 3 | let length: number = (value as string).length; |
| 4 | |
| 5 | // angle-bracket syntax (avoid in TSX/JSX files) |
| 6 | let length2: number = (<string>value).length; |
| 7 | |
| 8 | // Non-null assertion (!) — asserts value is not null/undefined |
| 9 | let element = document.getElementById("app")!; |
| 10 | element.innerHTML = "Hello"; // TS trusts it's not null |
| 11 | |
| 12 | // When assertions are useful: |
| 13 | // 1. DOM queries (getElementById returns HTMLElement | null) |
| 14 | let input = document.querySelector("input") as HTMLInputElement; |
| 15 | input.value = "default"; |
| 16 | |
| 17 | // 2. JSON parsing (returns any) |
| 18 | let data = JSON.parse(response) as User; |
| 19 | |
| 20 | // 3. When you know more than the compiler |
| 21 | let config = getEnvConfig() as { host: string; port: number }; |
| 22 | |
| 23 | // BAD: overusing assertions |
| 24 | let num: string | number = "hello"; |
| 25 | // (num as number).toFixed(2); // WRONG — will crash at runtime! |
| 26 | |
| 27 | // BETTER: use type narrowing |
| 28 | if (typeof num === "number") { |
| 29 | num.toFixed(2); // safe |
| 30 | } |
warning
Both type and interface create named types. For simple object shapes, they are largely interchangeable. The differences matter in specific scenarios.
| 1 | // Type alias — flexible, can represent anything |
| 2 | type ID = string | number; |
| 3 | type Point = { x: number; y: number }; |
| 4 | type Callback = (data: string) => void; |
| 5 | |
| 6 | // Interface — object shapes only, supports declaration merging |
| 7 | interface User { |
| 8 | name: string; |
| 9 | age: number; |
| 10 | } |
| 11 | |
| 12 | // Declaration merging — interfaces combine automatically |
| 13 | interface User { |
| 14 | email: string; |
| 15 | } |
| 16 | // User now has: name, age, email |
| 17 | |
| 18 | // Extending |
| 19 | interface Admin extends User { |
| 20 | role: "admin"; |
| 21 | } |
| 22 | |
| 23 | // Interface with methods |
| 24 | interface Repository { |
| 25 | findById(id: string): User | null; |
| 26 | findAll(): User[]; |
| 27 | save(user: User): void; |
| 28 | } |
| 29 | |
| 30 | // When to use which: |
| 31 | // type: unions, intersections, mapped types, anything non-object |
| 32 | // interface: object shapes, class contracts, when you need declaration merging |
| 33 | |
| 34 | // Declaration merging is unique to interfaces: |
| 35 | interface Window { |
| 36 | myCustomProp: string; |
| 37 | } |
| 38 | // This extends the global Window type — only works with interfaces |
best practice
These guidelines help you write clean, type-safe TypeScript that catches real bugs without becoming a type annotation burden.
| Guideline | Why |
|---|---|
| Enable strict mode | Catches the most errors from the start |
| Avoid any | Defeats type checking — use unknown instead |
| Annotate function signatures | Documents public API, catches regressions |
| Let inference work | Skip obvious annotations on local variables |
| Narrow before use | Use typeof/instanceof instead of assertions |
| Prefer unknown over any | Requires narrowing — safer by default |
| Use readonly for immutability | Prevents accidental mutations |
| Use branded types for IDs | Prevents mixing up UserId and OrderId |
| 1 | // Good: explicit function signatures |
| 2 | function getUser(id: string): User | null { |
| 3 | return db.users.find(u => u.id === id) ?? null; |
| 4 | } |
| 5 | |
| 6 | // Bad: any everywhere |
| 7 | function getUser(id: any): any { |
| 8 | return db.users.find((u: any) => u.id === id); |
| 9 | } |
| 10 | |
| 11 | // Good: unknown + narrowing |
| 12 | function parse(input: unknown): string { |
| 13 | if (typeof input === "string") return input; |
| 14 | if (typeof input === "number") return input.toString(); |
| 15 | throw new Error("Invalid input"); |
| 16 | } |
| 17 | |
| 18 | // Good: readonly for data that shouldn't change |
| 19 | type Config = { |
| 20 | readonly apiUrl: string; |
| 21 | readonly timeout: number; |
| 22 | }; |
| 23 | |
| 24 | // Good: branded types for IDs |
| 25 | type UserId = string & { __brand: "UserId" }; |
| 26 | type OrderId = string & { __brand: "OrderId" }; |
| 27 | |
| 28 | function createUserId(id: string): UserId { |
| 29 | return id as UserId; |
| 30 | } |