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.

Interfaces vs Types

Objects & Interfaces

Objects are the core of JavaScript, and TypeScript provides powerful ways to describe their shapes. Interfaces and type aliases are your primary tools for defining object structures. Think of an interface as a blueprint or contract — it does not create anything at runtime, but it defines the exact shape that any conforming object must follow. If an object claims to be a User, the interface guarantees it has an id, a name, and an email — no more guessing.

1. Interfaces

Interfaces define contracts for object shapes. They are the most common way to type objects in TypeScript, and they are one of the features teams adopt first because the payoff is immediate — autocomplete, refactoring safety, and self-documenting code.

Basic Interface

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

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

// Missing property error
const badUser: User = {
  id: 2,
  name: 'Bob'
  // ❌ Error: Property 'email' is missing
};

Optional Properties

interface Config {
  host: string;
  port: number;
  debug?: boolean;      // Optional
  timeout?: number;     // Optional
}

const config: Config = {
  host: 'localhost',
  port: 3000
  // debug and timeout are optional
};

Readonly Properties

interface Point {
  readonly x: number;
  readonly y: number;
}

const point: Point = { x: 10, y: 20 };
// point.x = 5; // ❌ Error: Cannot assign to 'x' because it is read-only

// ReadonlyArray
const numbers: readonly number[] = [1, 2, 3];
// numbers.push(4); // ❌ Error

2. Type Aliases vs Interfaces

Both can describe object shapes, and for everyday object typing, they are interchangeable. The “interface vs type” debate is one of the most common questions in TypeScript, and the honest answer is: it matters less than people think. That said, there are real differences that matter in specific situations.

Syntax Differences

// Interface
interface UserInterface {
  name: string;
  age: number;
}

// Type Alias
type UserType = {
  name: string;
  age: number;
};

// Both work the same for objects
const user1: UserInterface = { name: 'Alice', age: 25 };
const user2: UserType = { name: 'Bob', age: 30 };

Key Differences

// 1. Interfaces can be extended (declaration merging)
interface Animal {
  name: string;
}

interface Animal {
  age: number;
}

// Animal now has both name and age
const pet: Animal = { name: 'Fluffy', age: 3 };

// 2. Type aliases can represent primitives, unions, tuples
type ID = string | number;
type Point = [number, number];
type Name = string;

// 3. Type aliases can use mapped types
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
When to use which?
  • Use interfaces for objects that might be extended or implemented
  • Use type aliases for unions, intersections, tuples, or complex compositions
  • Both work for most object types; pick one style and be consistent

3. Extending Interfaces

Build complex types by extending existing ones.

Single Inheritance

interface Animal {
  name: string;
  age: number;
}

interface Dog extends Animal {
  breed: string;
  bark(): void;
}

const dog: Dog = {
  name: 'Max',
  age: 3,
  breed: 'Golden Retriever',
  bark() {
    console.log('Woof!');
  }
};

Multiple Inheritance

interface Printable {
  print(): void;
}

interface Loggable {
  log(): void;
}

interface Document extends Printable, Loggable {
  title: string;
  content: string;
}

const doc: Document = {
  title: 'Report',
  content: 'Lorem ipsum...',
  print() {
    console.log(this.content);
  },
  log() {
    console.log(`Document: ${this.title}`);
  }
};

Extending Type Aliases

// Types can extend interfaces
type Animal = {
  name: string;
};

interface Pet extends Animal {
  owner: string;
}

// Interfaces can extend types
interface Bird {
  fly(): void;
}

type Parrot = Bird & {
  speak(): void;
};

4. Intersection Types

Combine multiple types into one using the & operator. If interfaces are like inheriting from a parent, intersections are like mixing ingredients — you take everything from Type A and everything from Type B and combine them into a single type that has all properties from both.
type Person = {
  name: string;
  age: number;
};

type Employee = {
  employeeId: string;
  department: string;
};

// Intersection: the resulting type has ALL properties from both Person AND Employee
type Staff = Person & Employee;

const staff: Staff = {
  name: 'Alice',
  age: 30,
  employeeId: 'E001',
  department: 'Engineering'
};

Intersection with Conflicts

type A = { value: string };
type B = { value: number };

type C = A & B;
// C.value is of type 'never' (string & number = never)

// This is more common with method signatures
type WithTimestamp = {
  createdAt: Date;
};

type User = {
  name: string;
} & WithTimestamp;

const user: User = {
  name: 'Alice',
  createdAt: new Date()
};

5. Index Signatures

Define types for objects with dynamic keys. Index signatures are essential when you do not know all the property names ahead of time — like a dictionary, a cache, or a configuration object where users can add arbitrary keys.

String Index Signature

interface StringDictionary {
  [key: string]: string;
}

const colors: StringDictionary = {
  red: '#ff0000',
  green: '#00ff00',
  blue: '#0000ff'
};

// Any string key works
colors['purple'] = '#800080';
colors.orange = '#ffa500';

Number Index Signature

interface NumberArray {
  [index: number]: string;
}

const arr: NumberArray = ['a', 'b', 'c'];
console.log(arr[0]); // 'a'

Mixing Index Signatures with Properties

interface User {
  id: number;
  name: string;
  [key: string]: string | number; // Must be compatible
}

const user: User = {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  age: 25
};
When using index signatures, all explicit properties must have types compatible with the index signature.

6. Mapped Types

Transform existing types into new ones using a for...in-style syntax at the type level. Mapped types are one of TypeScript’s most powerful features — they let you derive new types from existing ones programmatically, rather than manually rewriting every property. Think of them as a “type factory” that takes one type as input and produces a modified version as output.

Basic Mapped Type

type User = {
  name: string;
  age: number;
  email: string;
};

// Make all properties optional -- this is exactly what Partial<User> does under the hood
// [K in keyof User] iterates over every key in User ('name' | 'age' | 'email')
// The ? makes each property optional; User[K] preserves the original value type
type PartialUser = {
  [K in keyof User]?: User[K];
};

// Make all properties readonly -- this is exactly what Readonly<User> does under the hood
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

Built-in Utility Types

TypeScript provides several utility types based on mapped types:
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// Partial<T> - All properties optional
type UpdateUser = Partial<User>;

// Required<T> - All properties required
type CompleteUser = Required<UpdateUser>;

// Readonly<T> - All properties readonly
type ImmutableUser = Readonly<User>;

// Pick<T, K> - Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;

// Omit<T, K> - Exclude specific properties
type PublicUser = Omit<User, 'password'>;

// Record<K, V> - Object with keys K and values V
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;

7. Template Literal Types

Create types from string patterns. Template literal types are one of TypeScript’s most unique features — no other mainstream language can express “a type that is any string matching this pattern.” They let you bring the same compile-time safety to strings that you have for objects and numbers.
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiPath = '/users' | '/products' | '/orders';

// TypeScript computes the cartesian product of all combinations automatically!
// This produces 12 valid endpoint strings (4 methods x 3 paths)
type ApiEndpoint = `${HttpMethod} ${ApiPath}`;
// 'GET /users' | 'GET /products' | 'GET /orders' | 'POST /users' | ...

// Transformations
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>; // 'onClick'
type FocusEvent = EventName<'focus'>; // 'onFocus'

Practical Example: CSS Properties

type CSSUnit = 'px' | 'em' | 'rem' | '%';
type CSSValue = `${number}${CSSUnit}`;

interface CSSProperties {
  width: CSSValue;
  height: CSSValue;
  margin: CSSValue;
}

const styles: CSSProperties = {
  width: '100px',
  height: '50%',
  margin: '1rem'
};

8. Recursive Types

Types that reference themselves. Recursive types model data structures that are inherently self-similar — trees, nested comments, JSON values, or file system directories. They are TypeScript’s answer to the question “how do I type something that can contain itself?“
// JSON value type
type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

const json: JsonValue = {
  name: 'Alice',
  age: 25,
  active: true,
  friends: ['Bob', 'Charlie'],
  metadata: {
    nested: {
      deeply: 'value'
    }
  }
};

// Tree structure
interface TreeNode<T> {
  value: T;
  children?: TreeNode<T>[];
}

const tree: TreeNode<string> = {
  value: 'root',
  children: [
    { value: 'child1' },
    {
      value: 'child2',
      children: [{ value: 'grandchild' }]
    }
  ]
};

9. Interface for Functions and Arrays

Interfaces can describe more than plain objects.

Function Interface

interface SearchFunction {
  (query: string, limit?: number): string[];
}

const search: SearchFunction = (query, limit = 10) => {
  // Implementation
  return [`Result for: ${query}`];
};

Array Interface

interface StringArray {
  [index: number]: string;
  length: number;
  push(item: string): number;
}

// Extending Array
interface NumberList extends Array<number> {
  sum(): number;
}

Hybrid Types

interface Counter {
  (start: number): void;  // Callable
  count: number;          // Property
  reset(): void;          // Method
}

function createCounter(): Counter {
  const counter = function (start: number) {
    counter.count = start;
  } as Counter;

  counter.count = 0;
  counter.reset = function () {
    counter.count = 0;
  };

  return counter;
}

const c = createCounter();
c(10);
console.log(c.count); // 10
c.reset();
console.log(c.count); // 0

10. Practical Patterns

Builder Pattern

interface UserBuilder {
  setName(name: string): this;
  setEmail(email: string): this;
  setAge(age: number): this;
  build(): User;
}

interface User {
  name: string;
  email: string;
  age?: number;
}

class UserBuilderImpl implements UserBuilder {
  private user: Partial<User> = {};

  setName(name: string): this {
    this.user.name = name;
    return this;
  }

  setEmail(email: string): this {
    this.user.email = email;
    return this;
  }

  setAge(age: number): this {
    this.user.age = age;
    return this;
  }

  build(): User {
    if (!this.user.name || !this.user.email) {
      throw new Error('Name and email are required');
    }
    return this.user as User;
  }
}

const user = new UserBuilderImpl()
  .setName('Alice')
  .setEmail('alice@example.com')
  .setAge(25)
  .build();

Repository Pattern

interface Repository<T, ID> {
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(entity: Omit<T, 'id'>): Promise<T>;
  update(id: ID, entity: Partial<T>): Promise<T>;
  delete(id: ID): Promise<boolean>;
}

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

class UserRepository implements Repository<User, number> {
  private users: User[] = [];

  async findById(id: number): Promise<User | null> {
    return this.users.find(u => u.id === id) || null;
  }

  async findAll(): Promise<User[]> {
    return [...this.users];
  }

  async create(entity: Omit<User, 'id'>): Promise<User> {
    const user = { ...entity, id: Date.now() };
    this.users.push(user);
    return user;
  }

  async update(id: number, entity: Partial<User>): Promise<User> {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) throw new Error('User not found');
    this.users[index] = { ...this.users[index], ...entity };
    return this.users[index];
  }

  async delete(id: number): Promise<boolean> {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return false;
    this.users.splice(index, 1);
    return true;
  }
}

Summary

ConceptExample
Interfaceinterface User { name: string }
Optional Property{ name?: string }
Readonly Property{ readonly id: number }
Extendinginterface Dog extends Animal
Intersectiontype Staff = Person & Employee
Index Signature{ [key: string]: number }
Mapped Types{ [K in keyof T]: T[K] }
Template Literalstype Event = `on${string}`
Utility TypesPartial<T>, Pick<T, K>, Omit<T, K>
Next, we’ll explore classes and object-oriented programming in TypeScript!

Interview Deep-Dive

Strong Answer:The “always use interfaces” advice is oversimplified. Both are valid, and the right choice depends on the use case.
  • Interfaces are better when: You are defining an object shape that will be extended or implemented. Interfaces support declaration merging — you can declare the same interface name twice and TypeScript combines them. This is essential for augmenting third-party types: declare module 'express' { interface Request { user?: User } } works because of declaration merging. You cannot do this with type aliases. Interfaces also produce better error messages because they have names that appear in diagnostics.
  • Type aliases are better when: You need unions, intersections, tuples, or computed types. type Result = Success | Failure requires a type alias. Mapped types, conditional types, and template literal types all require type aliases. An interface cannot represent a union of two shapes.
  • They are equivalent when: You are defining a simple object shape that will not be extended or merged. Pick one style for consistency and move on.
  • My approach: Interfaces for entities and contracts (things that classes implement or that represent domain objects), type aliases for everything else (unions, computed types, utility types, function signatures).
Follow-up: What is declaration merging, and can it be a footgun?Declaration merging means you can write interface Window { myCustomProp: string } and it adds myCustomProp to the existing Window interface rather than replacing it. This is powerful for extending global types and third-party libraries. The footgun is accidental merging: if two files independently declare interface Config { ... } with different properties, TypeScript silently merges them. You end up with a Config that has properties from both files, which may not be your intent. Type aliases prevent this because redeclaring a type alias is a compile error. This is one argument for preferring type aliases in application code and reserving interfaces for library type augmentation.
Strong Answer:Index signatures define the type for dynamic keys on an object — keys that are not known at compile time.
  • When they are useful: Configuration objects, dictionaries, caches, and any key-value mapping where the keys come from external data. Think of a translation file: { en: 'Hello', fr: 'Bonjour', de: 'Hallo' } — you do not know all the locale codes at compile time, but you know every value is a string.
  • The biggest pitfall: false confidence in property access. When you access dict['someKey'], TypeScript returns the value type (e.g., number) without undefined, even though the key might not exist. This is TypeScript lying to you for convenience. If you enable noUncheckedIndexedAccess in tsconfig.json, TypeScript correctly returns number | undefined for index signature access, forcing you to handle the missing-key case. Without this flag, const value: number = dict['nonexistent'] compiles without error but returns undefined at runtime.
  • The compatibility constraint: All explicitly declared properties must have types compatible with the index signature. If you write interface User { id: number; [key: string]: string }, it fails because id: number is not compatible with the index signature’s value type string.
  • Alternative: Record and Map. Record<string, number> is syntactic sugar for an index signature. For runtime dynamic keys, Map<string, number> is often better because it does not conflate your data keys with object prototype properties and has proper .has() and .get() methods.
Follow-up: What does noUncheckedIndexedAccess do, and should every project enable it?It makes TypeScript add | undefined to the return type of every index signature access and array index access. This is technically correct — arrays can be empty — but it requires nullish checks on every array access. My recommendation: enable it for new projects handling external data. For existing projects, the migration cost may not justify the safety gain.
Strong Answer:This is a subtle behavior that trips up even experienced TypeScript developers.
  • Compatible properties: merged normally. { name: string } & { age: number } produces { name: string; age: number }.
  • Identical properties: collapsed. { name: string } & { name: string } produces { name: string }.
  • Conflicting properties: resolved to never. { value: string } & { value: number } produces { value: string & number }, which is { value: never }. There is no value that is both a string and a number, so the intersection is never. You can declare a variable of this type, but you can never create a valid value for it.
  • Why this matters in practice: It comes up when composing types from different sources. If a library exports type WithId = { id: string } and your codebase has type Entity = { id: number }, intersecting them produces { id: never } — silently unusable. The fix is ensuring compatible types before intersecting, or using Omit to remove conflicting properties first.
  • Method signatures are different: If both types have a method with the same name but different signatures, the intersection creates an overloaded method. This is intentional and useful for composing interfaces with polymorphic methods.
Follow-up: How does this differ from union types with conflicting properties?With unions, TypeScript takes the union of the property types. For { value: string } | { value: number }, accessing value gives you string | number — because the value could come from either variant. You must narrow before using variant-specific methods. The key insight: intersection types combine the properties (you have ALL of them), while union types restrict to common properties (you can only access what is guaranteed across ALL variants).
Strong Answer:The Repository pattern is one of the most common interview design exercises, and the type design reveals how well you understand generics and utility types.
  • Base interface with two type parameters: interface Repository<T, ID> where T is the entity type and ID is the identifier type. Two parameters because not every entity uses the same ID type.
  • Create signature using Omit: create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>. The caller should not provide auto-generated fields. Omit removes them from the input type while the return type is the full entity.
  • Update signature using Partial: update(id: ID, entity: Partial<T>): Promise<T>. Partial updates are the norm — you rarely send every field.
  • FindById returns nullable: findById(id: ID): Promise<T | null>. Not T | undefined — null is the conventional “not found” signal in database contexts.
  • Constrain T with a base entity: T extends { id: ID } ensures every entity has an id field. This lets the repository implementation rely on entity.id without asserting.
The practical nuance: in a real codebase, you would separate the “input” type from the “stored” type. Utility types (Omit, Partial, Pick) derive these from a single source-of-truth type, so you maintain one definition and get all the variants automatically.Follow-up: How would you make this repository pattern testable? Would you use interfaces or abstract classes?I would define the repository as an interface, not an abstract class. Interfaces have no runtime presence — they are pure contracts. For testing, I create a mock implementation that stores data in a plain array without pulling in database dependencies. For production, a real implementation uses the database. The consuming code depends on the interface, not the implementation, following dependency inversion. Abstract classes would work but are heavier — they carry implementation code and cannot be multiply inherited. For a pure data-access contract, interfaces are the right tool.