Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

TypeScript Type System

TypeScript Fundamentals

TypeScript adds a static type system on top of JavaScript. Understanding types is the foundation of everything else in TypeScript. If you skip this chapter, nothing else will make sense — types are to TypeScript what notes are to music theory.

How TypeScript Works: Transpilation

TypeScript is not directly executed. Browsers and Node.js only understand JavaScript. TypeScript must be transpiled (compiled) to JavaScript before it can run. Think of TypeScript as writing in a markup language (like Markdown) — the final consumer (the browser) never sees the markup, only the rendered output (JavaScript). The types are scaffolding that helps you build correctly, then gets removed from the finished building.

The TypeScript Compilation Pipeline

StageWhat HappensCan You See It?
LexingSource code is broken into tokensInternal
ParsingTokens become an Abstract Syntax Tree (AST)ts.createSourceFile()
Type CheckingTypes are validated, errors reportedYour IDE red squiggles!
EmittingAST is transformed to JavaScriptThe .js files

Type Erasure

Here is the key insight that many beginners miss: Types exist only at compile time. They are completely erased in the output JavaScript. This means TypeScript has zero runtime cost — no performance penalty, no extra bytes shipped to users. But it also means you cannot use TypeScript types for runtime decisions (more on that below).
// TypeScript (input)
function greet(name: string): string {
  return `Hello, ${name}!`;
}

const user: { id: number; name: string } = {
  id: 1,
  name: 'Alice'
};
// JavaScript (output) - Types are gone!
function greet(name) {
  return `Hello, ${name}!`;
}

const user = {
  id: 1,
  name: 'Alice'
};
Runtime Implications: Since types are erased, you cannot use TypeScript types for runtime checks. You can’t do if (typeof x === "string") based on a TypeScript string type—that’s just JavaScript’s typeof.

The TypeScript Compiler (tsc)

The tsc command is the TypeScript compiler:
# Compile a single file
tsc hello.ts          # Outputs hello.js

# Compile with options
tsc --target ES2020 --module ESNext hello.ts

# Watch mode (recompile on changes)
tsc --watch

# Initialize a new project with tsconfig.json
tsc --init

# Compile entire project (uses tsconfig.json)
tsc

Compile Time vs Runtime Errors

Error TypeWhen CaughtExample
Compile-timeBefore code runsType mismatch, missing properties
RuntimeWhile code runsNetwork failures, user input
// Compile-time error (caught by TypeScript)
let name: string = 42; // ❌ Type 'number' is not assignable to 'string'

// Runtime error (NOT caught by TypeScript)
const data = JSON.parse(userInput); // Might throw if invalid JSON
Why This Matters: TypeScript’s value is catching bugs BEFORE your code runs. But it can’t protect you from external data (APIs, user input)—you still need runtime validation for that.

1. Type Annotations

Type annotations explicitly declare the type of a variable, parameter, or return value.
// Variable annotations
let name: string = 'Alice';
let age: number = 25;
let isActive: boolean = true;

// The annotation is after the variable name, separated by :
let count: number;  // Declared but not initialized
count = 42;         // Assigned later

Why Annotate?

let name: string = 'Alice';
name = 42; // ❌ Error: Type 'number' is not assignable to type 'string'
TypeScript catches type mismatches at compile time, not runtime.

2. Type Inference

TypeScript is smart. It can infer types from the value you assign — meaning you often do not need to write types at all. The compiler looks at the right-hand side of the assignment and figures out the type automatically. This is what makes TypeScript feel lightweight instead of verbose like Java.
// TypeScript infers these types automatically
let name = 'Alice';     // string (inferred)
let age = 25;           // number (inferred)
let isActive = true;    // boolean (inferred)

name = 42; // ❌ Error: Still type 'string'!
Best Practice: Let TypeScript infer types when the type is obvious from the value. Add explicit annotations when:
  • The type isn’t obvious
  • You’re declaring without initializing
  • You want to be explicit for documentation

When to Annotate vs Infer

// Let TypeScript infer (cleaner)
const user = { name: 'Alice', age: 25 };

// Explicit annotation (more control)
const user: { name: string; age: number } = { name: 'Alice', age: 25 };

// Always annotate function parameters
function greet(name: string): string {
  return `Hello, ${name}!`;
}

3. Primitive Types

TypeScript has the same primitive types as JavaScript, plus a few extras.

Basic Primitives

// string
let firstName: string = 'Alice';
let greeting: string = `Hello, ${firstName}`;

// number (integers and floats)
let age: number = 25;
let price: number = 19.99;
let hex: number = 0xff;
let binary: number = 0b1010;

// boolean
let isLoggedIn: boolean = true;
let hasAccess: boolean = false;

// null and undefined
let nothing: null = null;
let notDefined: undefined = undefined;

// symbol
let id: symbol = Symbol('id');

// bigint
let bigNumber: bigint = 9007199254740993n;

Special Types

// any - Opt out of type checking entirely (avoid in production code!)
// Using 'any' is like disabling your seatbelt -- it removes all protection.
let anything: any = 'hello';
anything = 42;        // No error -- TypeScript stops checking
anything = { foo: 'bar' }; // Anything goes

// unknown - The type-safe alternative to any.
// Think of 'unknown' as a locked box: you know something is inside,
// but you must prove what it is before you can use it.
let value: unknown = 'hello';
// value.toUpperCase(); // Error: must narrow the type first
if (typeof value === 'string') {
  console.log(value.toUpperCase()); // OK -- TypeScript now knows it is a string
}

// void - Function returns nothing meaningful
function log(message: string): void {
  console.log(message);
  // Implicitly returns undefined, which is fine for void
}

// never - Function NEVER returns (throws an error or runs forever)
// This is different from void: void returns undefined, never does not return at all.
function throwError(message: string): never {
  throw new Error(message);
}
Avoid any! It defeats the purpose of TypeScript. Use unknown if you truly don’t know the type, then narrow it with type guards.

4. Arrays

Arrays can be typed in two ways:
// Using Type[]
let numbers: number[] = [1, 2, 3, 4, 5];
let names: string[] = ['Alice', 'Bob', 'Charlie'];

// Using Array<Type> (generic syntax)
let scores: Array<number> = [100, 95, 88];

// Mixed types? Use union
let mixed: (string | number)[] = [1, 'two', 3, 'four'];

// Readonly arrays
const readonlyNumbers: readonly number[] = [1, 2, 3];
// readonlyNumbers.push(4); // ❌ Error: Property 'push' does not exist

Array Methods with Types

const numbers = [1, 2, 3, 4, 5];

// map - TypeScript infers the return type
const doubled = numbers.map(n => n * 2); // number[]

// filter - Returns same type
const evens = numbers.filter(n => n % 2 === 0); // number[]

// find - Returns Type | undefined
const found = numbers.find(n => n > 3); // number | undefined

// reduce - Explicit accumulator type
const sum = numbers.reduce((acc, n) => acc + n, 0); // number

5. Tuples

Tuples are fixed-length arrays with specific types at each position. While a regular array says “this is a list of strings,” a tuple says “this is exactly a string, then a number, then a boolean, in that order.” They are useful for representing structured data without creating a full interface — like function return values that pack multiple pieces of information together.
// Define a tuple
let person: [string, number] = ['Alice', 25];

// Access elements (typed!)
const name = person[0]; // string
const age = person[1];  // number

// Error on wrong type
person[0] = 42; // ❌ Error: Type 'number' is not assignable to type 'string'

// Error on extra elements (in strict mode)
person = ['Bob', 30, true]; // ❌ Error: Source has 3 elements but target allows only 2

Labeled Tuples (TS 4.0+)

type UserTuple = [name: string, age: number, isAdmin: boolean];

const user: UserTuple = ['Alice', 25, true];

// Labels are just for documentation, access is still by index
const name = user[0]; // string

Optional Tuple Elements

type Response = [number, string, object?];

const success: Response = [200, 'OK'];
const withData: Response = [200, 'OK', { user: 'Alice' }];

6. Enums

Enums define a set of named constants.

Numeric Enums

enum Direction {
  Up,     // 0
  Down,   // 1
  Left,   // 2
  Right   // 3
}

let move: Direction = Direction.Up;
console.log(move);             // 0
console.log(Direction[0]);     // 'Up' (reverse mapping)

// Custom values
enum Status {
  Pending = 1,
  Active = 2,
  Inactive = 3
}

String Enums

enum Color {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE'
}

let favorite: Color = Color.Blue;
console.log(favorite); // 'BLUE'

const Enums (Inlined at compile time)

const enum HttpStatus {
  OK = 200,
  NotFound = 404,
  ServerError = 500
}

const status = HttpStatus.OK; // Compiled to: const status = 200;
Modern Alternative: Many developers prefer union types over enums for better tree-shaking and simpler code:
type Direction = 'up' | 'down' | 'left' | 'right';

7. Object Types

Define the shape of objects with inline types or type aliases.

Inline Object Types

// Inline type annotation
let user: { name: string; age: number } = {
  name: 'Alice',
  age: 25
};

// Optional properties with ?
let config: { debug?: boolean; timeout: number } = {
  timeout: 3000
  // debug is optional
};

// Readonly properties
let point: { readonly x: number; readonly y: number } = { x: 10, y: 20 };
// point.x = 5; // ❌ Error: Cannot assign to 'x' because it is a read-only property

Type Aliases

Create reusable type definitions.
type User = {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
};

const alice: User = {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  isActive: true
};

const bob: User = {
  id: 2,
  name: 'Bob',
  email: 'bob@example.com',
  isActive: false
};

8. Union Types

A value can be one of several types.
// Basic union
let id: string | number;
id = 'abc123';
id = 123;

// Function with union parameter
function printId(id: string | number): void {
  console.log(`ID: ${id}`);
}

printId('abc');
printId(123);

Narrowing Union Types

function printId(id: string | number): void {
  if (typeof id === 'string') {
    // TypeScript knows id is string here
    console.log(id.toUpperCase());
  } else {
    // TypeScript knows id is number here
    console.log(id.toFixed(2));
  }
}

Literal Types

// Specific string values
type Status = 'pending' | 'active' | 'inactive';

let userStatus: Status = 'active';
// userStatus = 'unknown'; // ❌ Error: Type '"unknown"' is not assignable

// Specific numbers
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

function roll(): DiceRoll {
  return Math.ceil(Math.random() * 6) as DiceRoll;
}

9. Type Assertions

Tell TypeScript you know better about the type. Assertions do not change the runtime value — they only override the compiler’s type analysis. Think of it as telling the compiler “trust me, I know what this is” rather than actually converting anything.
// as syntax (preferred) -- "I know this element is specifically an input, not just any element"
const input = document.getElementById('username') as HTMLInputElement;
input.value = 'Alice'; // Now TypeScript allows .value access

// Angle bracket syntax (not usable in JSX/TSX files because it conflicts with HTML tags)
const input2 = <HTMLInputElement>document.getElementById('password');

// Double assertion (escape hatch -- bypasses type safety completely)
// You almost never need this. If you do, it usually means your types are wrong.
const x = 'hello' as unknown as number; // Dangerous: no runtime conversion happens!

Non-null Assertion

function getLength(value: string | null): number {
  // Tell TypeScript value is definitely not null
  return value!.length; // ⚠️ Use only when you're certain
}

// Better approach: actually check
function getLengthSafe(value: string | null): number {
  if (value === null) {
    return 0;
  }
  return value.length;
}
Use assertions sparingly! They override TypeScript’s type checking. If you’re wrong, you’ll get runtime errors. Prefer type guards for safety.

10. Type Narrowing

Type narrowing is how you go from a broad type to a specific one inside a code block. TypeScript’s control flow analysis tracks which branches you have taken and automatically narrows the type — this is one of the most powerful and practical features of the type system.

typeof Guard

function process(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase();
  }
  return value * 2;
}

Truthiness Narrowing

function printName(name: string | null | undefined) {
  if (name) {
    console.log(name.toUpperCase()); // name is string
  } else {
    console.log('No name provided');
  }
}

instanceof Guard

function logDate(date: Date | string) {
  if (date instanceof Date) {
    console.log(date.toISOString());
  } else {
    console.log(new Date(date).toISOString());
  }
}

in Operator

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    animal.swim();
  } else {
    animal.fly();
  }
}

Summary

ConceptExample
Type Annotationlet name: string = 'Alice'
Type Inferencelet age = 25 (inferred as number)
Arrayslet nums: number[] = [1, 2, 3]
Tupleslet pair: [string, number] = ['a', 1]
Union Typeslet id: string | number
Literal Typestype Status = 'on' | 'off'
Type Aliasestype User = { name: string }
Type Assertionsvalue as string
Type Guardstypeof, instanceof, in
Next, we’ll explore functions and how TypeScript makes them safer and more expressive!

Interview Deep-Dive

Strong Answer:These three types represent the extremes of TypeScript’s type system, and confusing them is a common interview red flag.
  • any: The escape hatch. It disables all type checking for that value — you can call any method, access any property, assign it to anything. Think of it as telling the compiler “stop looking at this.” It is dangerous because errors that TypeScript would normally catch at compile time become runtime exceptions. The only legitimate uses are: migrating a large JavaScript codebase incrementally (temporarily marking untyped code as any), and interacting with truly dynamic third-party code where writing types is impractical. In production TypeScript, having any in your codebase is technical debt.
  • unknown: The type-safe alternative to any. It says “this value could be anything, but you must prove what it is before you use it.” You cannot access properties, call methods, or assign unknown to a typed variable without narrowing first (via typeof, instanceof, or a custom type guard). This is the correct type for external data: API responses, JSON.parse() output, user input, catch block errors. You accept the data as unknown, validate its shape, and only then work with it as a typed value.
  • never: Represents values that can never occur. A function that always throws returns never. A variable in the default branch of an exhaustive switch is never (if you handled all cases, no value can reach here). The most powerful use of never is exhaustiveness checking: const _exhaustive: never = shape; in a switch default forces a compile error if you add a new union variant without handling it.
The mental model: any is “I do not care about types,” unknown is “I do not know the type yet but I will find out,” and never is “this should be impossible.”Follow-up: Show me how ‘never’ is used for exhaustiveness checking in a discriminated union.Consider type Action = { type: 'add' } | { type: 'delete' } | { type: 'update' } and a switch on action.type. If you handle all three cases, the default branch’s action is narrowed to never — no value can reach it. Assigning const _: never = action compiles fine. Now if someone adds { type: 'archive' } to the union but forgets to add a case, the default branch sees action as { type: 'archive' }, which is not assignable to never, and the compiler errors immediately. This turns a potential runtime bug into a compile-time error, which is exactly the value proposition of TypeScript.
Strong Answer:Type erasure is the fundamental architectural decision of TypeScript: all type annotations, interfaces, type aliases, and generic type parameters are removed during compilation. The output JavaScript has zero traces of the type system.
  • What this means: interface User { name: string; email: string } does not exist at runtime. There is no User object, no User class, no User symbol in the compiled JavaScript. If you write if (x instanceof User), it is a compile error because User is not a value — it is a type that was erased.
  • Why this matters: You cannot validate incoming data against a TypeScript interface at runtime. JSON.parse(apiResponse) returns any (or unknown if you are careful), and no amount of TypeScript types can verify that the parsed object actually matches your User interface. The JSON could contain { name: 42, email: null } and TypeScript would not catch it because the type system is gone at runtime.
  • The solution: Runtime validation libraries (Zod, io-ts, Yup) or custom type guard functions. Zod, for example, lets you define a schema that serves as both a runtime validator and a TypeScript type: const UserSchema = z.object({ name: z.string(), email: z.string().email() }); type User = z.infer<typeof UserSchema>;. Now UserSchema.parse(data) validates at runtime, and User provides compile-time types — single source of truth.
  • Classes are the exception: TypeScript classes compile to JavaScript classes, so instanceof works on classes. This is why some teams prefer classes over interfaces for domain entities that need runtime validation.
Follow-up: How does type erasure affect generic types at runtime?Generic type parameters are completely erased. function identity<T>(value: T): T compiles to function identity(value) { return value; } — there is no way to know at runtime what T was. You cannot write if (T === string) inside the function because T does not exist at runtime. If you need different behavior based on the type, you must pass a runtime discriminator (an explicit string tag, a class constructor, or a type guard function) alongside the value.
Strong Answer:The rule I follow is: let TypeScript infer when the type is obvious from the assignment, annotate when it is not obvious or when you are defining a contract boundary.
  • Infer for local variables: const name = 'Alice' — the type string is obvious. Adding : string is visual noise that makes the code harder to scan. Same for const users = [{ id: 1, name: 'Alice' }] — TypeScript infers the full array type correctly.
  • Annotate function parameters always: function greet(name) gives name the type any (with strict mode, it is an error). Function parameters are contract boundaries — the caller needs to know what to pass. function greet(name: string) is a contract.
  • Annotate function return types for public APIs: For exported functions, library functions, or anything that forms a module boundary, explicit return types serve as documentation and prevent accidental type changes. If you refactor the function body and accidentally change the return type, an explicit annotation catches it. For internal helper functions, return type inference is usually fine.
  • Annotate when inference gets it wrong: const status = 'active' infers the literal type 'active', not string. If you want string, you annotate. Conversely, let status = 'active' infers string (because let can be reassigned). If you want the literal type, use as const or annotate.
  • Annotate empty initializations: const items = [] infers any[]. You must annotate: const items: string[] = [].
The key insight is that inference and annotation serve different purposes. Inference reduces verbosity for the code author. Annotations enforce contracts at boundaries for the code consumer. The best TypeScript codebases use inference liberally inside functions and annotations strictly at function signatures and module boundaries.Follow-up: What happens when TypeScript infers a type that is more specific than you want?This comes up with const declarations and object literals. const config = { timeout: 3000 } infers { timeout: number }, which is fine. But const method = 'GET' infers the literal type 'GET', not string. If you pass it to a function expecting string, it works (literals are subtypes of string). But if a function returns a string and you compare it to method, TypeScript may warn about comparing string to 'GET'. The tools are: as const to make types narrower (literal), explicit annotation to make types wider (general), and satisfies (TypeScript 4.9+) to validate a type without widening it.
Strong Answer:Control flow narrowing is one of TypeScript’s most sophisticated features, and it is what makes union types practical rather than painful.
  • The mechanism: TypeScript’s type checker tracks the type of every variable through every branch of your code. When you write if (typeof x === 'string'), TypeScript narrows the type of x to string inside the if block and to “whatever was left” in the else block. If x was string | number, it becomes string in the if and number in the else.
  • What triggers narrowing: typeof checks, instanceof checks, in operator ('swim' in animal), equality checks (x === null), truthiness checks (if (x)), and custom type guards (function isFish(x): x is Fish). TypeScript also narrows on assignment: if you assign a string to a string | number variable, the type narrows to string after the assignment.
  • Control flow analysis: TypeScript follows the actual control flow, not just the immediate block. If you check if (x === null) return;, TypeScript knows that after the return, x is not null for the rest of the function. This works with throw, return, break, and continue — any statement that makes a code path unreachable narrows the types for the remaining paths.
  • The limitation: Narrowing is per-reference, not per-value. If you narrow obj.value to string inside an if block, TypeScript narrows it for that block. But if you call a function in between that could mutate obj, TypeScript may widen the type back because it cannot guarantee the function did not change obj.value. This is why storing narrowed values in local constants (const val = obj.value; if (typeof val === 'string') ...) is a common pattern — local constants cannot be mutated by external calls.
Follow-up: How do custom type guard functions work, and why are they sometimes better than inline typeof checks?A custom type guard is a function whose return type is a type predicate: function isUser(data: unknown): data is User. When this function returns true, TypeScript narrows the argument to User in the calling scope. The advantage over inline checks is reusability and encapsulation. Validating that an API response is a User requires checking multiple properties (typeof data.name === 'string' && typeof data.id === 'number' && ...). Inlining that check everywhere is verbose and error-prone. Encapsulating it in isUser() means you write the validation once, test it once, and use it everywhere. TypeScript propagates the narrowing wherever you call it.