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.

Functions & Types

Functions are the building blocks of any application. TypeScript makes functions safer by typing parameters, return values, and function signatures themselves. Think of typed function parameters as a form with labeled fields — instead of accepting “anything” and hoping the caller passes the right data, you specify exactly what each slot expects. The compiler then rejects any call that does not match the form.

1. Function Type Annotations

Parameter and Return Types

// Annotate parameters and return type
function add(a: number, b: number): number {
  return a + b;
}

// Arrow function
const multiply = (a: number, b: number): number => a * b;

// Return type is often inferred
const divide = (a: number, b: number) => a / b; // Returns number

void and never

// void - function completes but does not return a meaningful value
// The caller should not use the return value for anything
function log(message: string): void {
  console.log(message);
}

// never - function NEVER reaches its end -- it either throws or loops forever
// This is a signal to both humans and the compiler: "code after this is unreachable"
function throwError(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {} // Execution never moves past this point
}
Practical tip: The never type is especially useful in exhaustive switch statements. If you handle all cases, the default branch’s variable becomes never — and if you add a new case later without handling it, the compiler errors. This is a powerful safety net for growing codebases.

2. Optional and Default Parameters

Optional Parameters

function greet(name: string, greeting?: string): string {
  return `${greeting || 'Hello'}, ${name}!`;
}

greet('Alice');           // 'Hello, Alice!'
greet('Alice', 'Hi');     // 'Hi, Alice!'

Default Parameters

function greet(name: string, greeting: string = 'Hello'): string {
  return `${greeting}, ${name}!`;
}

greet('Alice');           // 'Hello, Alice!'
greet('Alice', 'Hi');     // 'Hi, Alice!'
Optional vs Default: Optional parameters can be undefined. Default parameters have a fallback value. Default parameters are usually preferred.

Rest Parameters

function sum(...numbers: number[]): number {
  return numbers.reduce((total, n) => total + n, 0);
}

sum(1, 2, 3);        // 6
sum(1, 2, 3, 4, 5);  // 15

3. Function Type Expressions

Define the type of a function itself — not what it does, but what its shape looks like. This is like defining a “job description” that any function matching the signature can fill.
// Type alias for a function -- any function with this signature qualifies
type MathOperation = (a: number, b: number) => number;

const add: MathOperation = (a, b) => a + b;
const subtract: MathOperation = (a, b) => a - b;

// As a parameter
function calculate(a: number, b: number, operation: MathOperation): number {
  return operation(a, b);
}

calculate(10, 5, add);      // 15
calculate(10, 5, subtract); // 5

Call Signatures

// Object type with call signature
type DescribableFunction = {
  description: string;
  (x: number): number;
};

function double(x: number): number {
  return x * 2;
}
double.description = 'Doubles the input';

const fn: DescribableFunction = double;

Construct Signatures

// For classes/constructors
type UserConstructor = {
  new (name: string): User;
};

class User {
  constructor(public name: string) {}
}

function createUser(ctor: UserConstructor, name: string): User {
  return new ctor(name);
}

const user = createUser(User, 'Alice');

4. Function Overloads

Define multiple function signatures for different parameter combinations. Overloads let you express something that a single signature cannot: “if you pass a string, you get back X; if you pass a number, you get back Y.” The compiler uses the overload signatures (the declarations) to check your call sites, while the implementation signature (the body) handles the actual logic.
// Overload signatures
function format(value: string): string;
function format(value: number): string;
function format(value: Date): string;

// Implementation signature
function format(value: string | number | Date): string {
  if (typeof value === 'string') {
    return value.toUpperCase();
  } else if (typeof value === 'number') {
    return value.toFixed(2);
  } else {
    return value.toISOString();
  }
}

format('hello');           // 'HELLO'
format(3.14159);          // '3.14'
format(new Date());       // '2024-01-01T00:00:00.000Z'

More Complex Overloads

// Return different types based on input
function createElement(tag: 'div'): HTMLDivElement;
function createElement(tag: 'span'): HTMLSpanElement;
function createElement(tag: 'input'): HTMLInputElement;
function createElement(tag: string): HTMLElement;

function createElement(tag: string): HTMLElement {
  return document.createElement(tag);
}

const div = createElement('div');    // HTMLDivElement
const span = createElement('span');  // HTMLSpanElement
const input = createElement('input'); // HTMLInputElement
const custom = createElement('custom'); // HTMLElement
Overloads Rule: The implementation signature must be compatible with all overload signatures but is not visible to callers.

5. Generic Functions

Write functions that work with any type while maintaining type safety. Generics are like templates or molds — you define the shape once, and the caller fills in the specific type. Without generics, you would have to choose between type safety (writing separate functions for each type) and flexibility (using any and losing all type information).

Basic Generics

// Without generics -- the return type is 'any', so TypeScript forgets what went in
function identity(value: any): any {
  return value;
}

// With generics -- T is a placeholder that the caller fills in
// Whatever type goes in, the same type comes out. Type information is preserved!
function identity<T>(value: T): T {
  return value;
}

const num = identity(42);        // number
const str = identity('hello');   // string
const obj = identity({ x: 1 });  // { x: number }

Multiple Type Parameters

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const p1 = pair('hello', 42);           // [string, number]
const p2 = pair(true, { name: 'Alice' }); // [boolean, { name: string }]

Generic Constraints

// Constrain T to have a length property.
// "extends" here means "must satisfy this shape" -- it is a contract requirement.
// Without this constraint, TypeScript would error on value.length because
// not every type T has a .length property.
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength('hello');     // 5 -- strings have .length
getLength([1, 2, 3]);   // 3 -- arrays have .length
getLength({ length: 10 }); // 10 -- any object with .length works
// getLength(123);      // Error: number does not have .length

keyof Constraint

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 25 };

getProperty(user, 'name'); // string
getProperty(user, 'age');  // number
// getProperty(user, 'email'); // ❌ Error: 'email' is not a key of user

6. Type Guards

Functions that narrow types at runtime. Type guards bridge the gap between TypeScript’s compile-time type system and JavaScript’s runtime behavior. They let you write conditional logic that both the runtime and the compiler understand.

typeof Guards

function processValue(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();
  }
  return value.toString();
}

instanceof Guards

class Dog {
  bark() { console.log('Woof!'); }
}

class Cat {
  meow() { console.log('Meow!'); }
}

function makeSound(animal: Dog | Cat): void {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

Custom Type Guards (Type Predicates)

Custom type guards are one of the most powerful patterns in TypeScript. The is keyword in the return type tells the compiler: “if this function returns true, treat the parameter as the specified type.” This lets you encapsulate complex runtime checks in a reusable function.
interface Fish {
  swim: () => void;
}

interface Bird {
  fly: () => void;
}

// The return type "animal is Fish" is a type predicate.
// It tells TypeScript: "when this returns true, narrow 'animal' to Fish"
function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird): void {
  if (isFish(animal)) {
    animal.swim(); // TypeScript knows it is Fish -- .swim() is safe
  } else {
    animal.fly();  // TypeScript knows it is Bird -- .fly() is safe
  }
}
Practical tip: Custom type guards are essential for validating API responses. You can write an isUser(data: unknown): data is User function that validates the shape of external data, giving you both runtime safety and compile-time narrowing in one step.

Discriminated Unions

// Discriminated unions use a common "tag" property (here: 'kind') to distinguish variants.
// Each branch of the switch narrows the type to the specific variant.
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // TypeScript narrows: shape is { kind: 'circle'; radius: number }
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      // TypeScript narrows: shape is { kind: 'rectangle'; width: number; height: number }
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    // If you add a new shape variant later (e.g., 'pentagon') and forget to handle it,
    // TypeScript will error here if you add exhaustiveness checking (see Advanced Types chapter).
  }
}

const circle: Shape = { kind: 'circle', radius: 5 };
getArea(circle); // 78.54

7. Assertion Functions

Functions that throw if a condition is false, narrowing the type for all code that follows. Unlike type predicates (which narrow inside an if block), assertion functions narrow the type for the rest of the function scope — everything below the assertion call benefits from the narrowed type.
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error('Value must be a string');
  }
}

function processInput(input: unknown): string {
  assertIsString(input);
  // TypeScript knows input is string after assertion
  return input.toUpperCase();
}

Non-null Assertion

function assertDefined<T>(value: T | null | undefined): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error('Value must be defined');
  }
}

function getUser(id: string): User | null {
  // ... fetch user
  return null;
}

const user = getUser('123');
assertDefined(user);
// TypeScript knows user is User (not null) after this point
console.log(user.name);

8. this Parameter

Explicitly type the this context.
interface User {
  name: string;
  greet(this: User): string;
}

const user: User = {
  name: 'Alice',
  greet() {
    return `Hello, ${this.name}!`;
  }
};

user.greet(); // 'Hello, Alice!'

// This would error because 'this' context is wrong
const greet = user.greet;
// greet(); // ❌ Error: 'this' context of type 'void' is not assignable

This Parameter in Classes

class Counter {
  count = 0;

  // Arrow function preserves 'this'
  increment = () => {
    this.count++;
  };

  // Regular method with explicit this type
  decrement(this: Counter): void {
    this.count--;
  }
}

9. Callback Types

Type callbacks precisely.
// Simple callback
type Callback = (result: string) => void;

function fetchData(callback: Callback): void {
  setTimeout(() => {
    callback('Data loaded');
  }, 1000);
}

// Callback with error handling
type NodeCallback<T> = (error: Error | null, result: T | null) => void;

function readFile(path: string, callback: NodeCallback<string>): void {
  // ...
}

// Event handler
type EventHandler<E extends Event> = (event: E) => void;

const handleClick: EventHandler<MouseEvent> = (event) => {
  console.log(event.clientX, event.clientY);
};

10. Practical Examples

API Function Types

interface User {
  id: number;
  name: string;
  email: string;
}

type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const response = await fetch('/api/users');
  return response.json();
}

Event Emitter Pattern

type EventMap = {
  login: { userId: string };
  logout: { userId: string };
  error: { message: string; code: number };
};

type EventCallback<T> = (data: T) => void;

class EventEmitter<T extends Record<string, any>> {
  private listeners = new Map<keyof T, Set<EventCallback<any>>>();

  on<K extends keyof T>(event: K, callback: EventCallback<T[K]>): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    this.listeners.get(event)?.forEach((callback) => callback(data));
  }
}

const emitter = new EventEmitter<EventMap>();

emitter.on('login', (data) => {
  console.log(`User ${data.userId} logged in`);
});

emitter.emit('login', { userId: '123' }); // Type-safe!

Summary

ConceptExample
Parameter Typesfunction add(a: number, b: number)
Return Typesfunction add(a, b): number
Optional Paramsfunction greet(name: string, greeting?: string)
Default Paramsfunction greet(name: string, greeting = 'Hello')
Rest Paramsfunction sum(...nums: number[])
Function Typestype Fn = (x: number) => number
OverloadsMultiple signatures + implementation
Genericsfunction identity<T>(value: T): T
Type Guardsfunction isFish(x): x is Fish
Assertionsfunction assert(x): asserts x is string
Next, we’ll explore objects and interfaces in depth!

Interview Deep-Dive

Strong Answer:This is a design decision that many TypeScript developers get wrong because they default to overloads when simpler alternatives exist.
  • Use overloads when: The return type depends on the input type, and you want the caller to get the precise return type based on what they passed. Classic example: createElement('div') returns HTMLDivElement, createElement('input') returns HTMLInputElement. A single signature createElement(tag: string): HTMLElement loses this precision — the caller always gets the generic HTMLElement and must cast. Overloads preserve the mapping from specific input to specific output.
  • Use union return types when: The function legitimately returns different types regardless of input, or the relationship between input and output is not 1-to-1. For example, function parse(input: string): number | Error — every input is a string, and the output depends on the content, not the type. Overloads would not help here because there is no type-level distinction between “a string that parses” and “a string that does not.”
  • The trade-off: Overloads add complexity. The implementation signature must be compatible with all overload signatures, which often means the implementation body is full of type assertions and conditional logic. If you have 5 overloads, you have 5 signatures to maintain. Generics with conditional types often achieve the same result with less code: function identity<T extends string | number>(value: T): T extends string ? string : number gives you input-dependent return types without overloads.
  • My rule of thumb: If you have 2-3 distinct input-output mappings, overloads are clear and readable. If you have more, generics with conditional types are usually cleaner. If the return type does not depend on the input type, just use a union.
Follow-up: What is the gotcha with the implementation signature in overloads?The implementation signature is not visible to callers — only the overload signatures are. This means if the implementation accepts string | number | Date, but you only have overloads for string and number, callers cannot pass a Date even though the implementation handles it. The overload signatures act as the public API; the implementation signature is an internal detail. A common mistake is writing an implementation signature that is too narrow, causing one of the overloads to be incompatible, which TypeScript flags as an error.
Strong Answer:Both are tools for narrowing types, but they work in fundamentally different scopes and have different failure modes.
  • Type predicates (is): Used in if conditions. function isString(x: unknown): x is string narrows the type inside the if block where the return value is true. The function returns a boolean — it does not throw. The caller decides what to do when the check fails (return early, show an error, use a default). Narrowing is limited to the if block.
  • Assertion functions (asserts): Narrow the type for the entire remaining scope. function assertIsString(x: unknown): asserts x is string throws if the condition fails. After the call, TypeScript treats x as string for every line that follows — not just inside an if block. The function either succeeds (and narrows) or throws (and execution stops). There is no “else” path.
  • When to use type predicates: Filtering, branching, optional handling. const users = items.filter(isUser) uses a type predicate to narrow the array type. if (isAdmin(user)) { showAdminPanel(); } — the non-admin path is a valid execution path, not an error.
  • When to use assertion functions: Validation at boundaries where invalid data is a bug, not an expected condition. assertDefined(config.apiKey) at application startup — if the API key is missing, crashing immediately with a clear error message is the correct behavior. Assertion functions are also useful in test setup: assertIsHTMLElement(element) before interacting with it.
  • The danger of assertion functions: If your assertion is wrong (the function returns without throwing when it should have thrown), TypeScript trusts you and narrows the type incorrectly. This is a silent type safety violation. Type predicates are safer because they return a boolean that can be unit-tested; assertion functions are harder to test for the “should have thrown but did not” case.
Follow-up: Can you use assertion functions with array methods like filter?No. Array.prototype.filter expects a function returning boolean, and assertion functions return void (they assert or throw). You must use type predicates with filter: items.filter((item): item is User => isUser(item)). Assertion functions are for imperative code paths where you want to “gate” execution, not for declarative operations like filtering or mapping.
Strong Answer:Generic constraints are how you tell TypeScript “this type parameter is not completely open — it must satisfy certain requirements.” Without constraints, generics are too permissive; with them, you get precision.
  • The problem without constraints: function getLength<T>(value: T): number { return value.length; } fails because TypeScript does not know that T has a length property. T could be number, which has no length. The compiler correctly rejects this.
  • The solution with extends: function getLength<T extends { length: number }>(value: T): number tells TypeScript: “T can be any type, as long as it has a length property of type number.” Now strings, arrays, and any object with length work, but numbers are rejected at the call site.
  • Where keyof elevates this: function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] constrains K to be one of the actual keys of T. If T is { name: string, age: number }, then K can only be 'name' or 'age'. The return type T[K] is automatically the type of that specific property — if you pass 'name', you get string; if you pass 'age', you get number. Without keyof, you would have to accept key: string and return any, losing all type safety.
  • Real-world example: A type-safe pick function: function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>. This guarantees at compile time that you can only pick keys that exist on the object, and the return type precisely reflects which keys you picked. This is impossible without the keyof constraint.
Follow-up: What is the difference between T extends keyof U and K extends keyof T in terms of how the types are resolved?K extends keyof T constrains K to be a union of string literal types representing the keys of T. When you call getProperty(user, 'name'), TypeScript infers T = typeof user and K = 'name'. The return type T[K] resolves to T['name'] which is string. If T is generic (not yet resolved), T[K] remains a deferred type that resolves when T is known. The key insight is that keyof produces a union of literal types at the type level, not a runtime value — it is purely a compile-time constraint that prevents invalid property access.
Strong Answer:The this parameter is TypeScript’s solution to one of JavaScript’s most notorious footguns: the value of this depends on how a function is called, not how it is defined.
  • The problem: In JavaScript, if you extract a method from an object and call it standalone, this is undefined (strict mode) or the global object (sloppy mode). const greet = user.greet; greet();this.name is undefined inside greet because the method lost its binding. This is the root cause of countless bugs in event handlers: button.addEventListener('click', user.handleClick) — inside handleClick, this is the button element, not the user object.
  • TypeScript’s solution: The this parameter is a fake first parameter that is erased at compile time (like type annotations). greet(this: User): string tells TypeScript: “this function must be called with this bound to a User.” If you try to call it without the right context, TypeScript errors. This catches the detached method bug at compile time.
  • In classes, arrow functions are the pragmatic fix: increment = () => { this.count++; } captures this lexically from the class instance. It is safe to pass as a callback because this always refers to the instance. The trade-off is that arrow methods are per-instance (each instance gets its own copy), not on the prototype. For a class with 10,000 instances, that is 10,000 copies of the function instead of one shared prototype method. For UI components (where instances are few), this is irrelevant. For data classes in tight loops, it can matter.
  • strictBindCallApply config: When enabled, TypeScript checks that Function.prototype.bind, .call, and .apply are called with the correct types. greet.call({ name: 42 }) would error because { name: 42 } does not match the this: User parameter. Without this flag, these calls are unchecked.
Follow-up: When would you choose an explicit this parameter over an arrow function in a class?When you want the method on the prototype (shared across instances) and you control all call sites. For a library API where you document “call this method on the object, do not extract it,” the this parameter provides compile-time enforcement without the per-instance memory cost. Arrow functions are better when the method will be passed as a callback (event handlers, React callbacks, Promise chains) because you cannot control how the caller invokes it. The judgment call is: “will this method ever be detached from its object?” If yes, arrow function. If no, prototype method with this parameter.