Episode 1 — Fundamentals / 1.25 — TypeScript Essentials

1.25.h — Generic Interfaces and Constraints

In one sentence: Generic interfaces, constraints (extends), the keyof operator, mapped types, and utility types like Partial<T>, Pick<T, K>, and Omit<T, K> let you build flexible, reusable type structures that enforce precise contracts.

Navigation: ← 1.25 Overview · 1.25.i — TypeScript Tooling →


1. Generic interfaces

Interfaces can accept type parameters just like functions:

// A box that holds any type of value
interface Box<T> {
  value: T;
  label: string;
}

const stringBox: Box<string> = { value: "hello", label: "greeting" };
const numberBox: Box<number> = { value: 42, label: "answer" };
const userBox: Box<User> = { value: { id: 1, name: "Alice" }, label: "user" };

// Generic interface with multiple parameters
interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

const pair: KeyValuePair<string, number> = { key: "age", value: 30 };

Generic API response pattern

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

interface PaginatedResponse<T> {
  items: T[];
  page: number;
  pageSize: number;
  totalItems: number;
  totalPages: number;
}

// Usage:
interface User { id: number; name: string; email: string; }
interface Product { id: number; title: string; price: number; }

type UserResponse = ApiResponse<User>;
type ProductListResponse = ApiResponse<PaginatedResponse<Product>>;

async function fetchUsers(page: number): Promise<ApiResponse<PaginatedResponse<User>>> {
  const res = await fetch(`/api/users?page=${page}`);
  return res.json();
}

const result = await fetchUsers(1);
result.data.items[0].name;  // string — fully typed through all layers!
result.data.totalPages;     // number

2. Generic type aliases

Type aliases can also be generic:

// Nullable wrapper
type Nullable<T> = T | null;

let name: Nullable<string> = "Alice";
name = null;  // OK

// Result type (success or error)
type Result<T, E = string> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number> {
  if (b === 0) return { ok: false, error: "Division by zero" };
  return { ok: true, value: a / b };
}

const result = divide(10, 3);
if (result.ok) {
  console.log(result.value.toFixed(2));  // "3.33"
} else {
  console.log(result.error);             // string
}

// Generic function type alias
type Transform<Input, Output> = (value: Input) => Output;

const stringify: Transform<number, string> = (n) => n.toString();
const parse: Transform<string, number> = (s) => parseInt(s, 10);

3. Constraints with extends

Constraints restrict which types a generic parameter can accept:

// T must have a 'length' property
function logLength<T extends { length: number }>(value: T): number {
  console.log(value.length);
  return value.length;
}

logLength("hello");          // OK — string has .length → 5
logLength([1, 2, 3]);        // OK — array has .length → 3
logLength({ length: 10 });   // OK — object has .length → 10
// logLength(42);             // Error: number doesn't have .length
// logLength(true);           // Error: boolean doesn't have .length

// Constraint with an interface
interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

interface User extends HasId { name: string; email: string; }
interface Product extends HasId { title: string; price: number; }

const users: User[] = [{ id: 1, name: "Alice", email: "a@b.com" }];
const products: Product[] = [{ id: 1, title: "Widget", price: 9.99 }];

findById(users, 1);     // User | undefined
findById(products, 1);  // Product | undefined
// findById([{ name: "no id" }], 1);  // Error: missing 'id' property

// Constraining one type parameter with another
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30 };
getProperty(user, "name");   // string
getProperty(user, "age");    // number
// getProperty(user, "email"); // Error: "email" not in keyof typeof user

4. keyof operator

keyof produces a union of all keys of a type:

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

type UserKeys = keyof User;
// UserKeys = "id" | "name" | "email" | "isActive"

// Practical use: type-safe property access
function getValue<T>(obj: T, key: keyof T): T[keyof T] {
  return obj[key];
}

// Even more precise with two type params:
function getTypedValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: User = { id: 1, name: "Alice", email: "a@b.com", isActive: true };

const name = getTypedValue(user, "name");      // string (not string | number | boolean)
const id = getTypedValue(user, "id");          // number
const active = getTypedValue(user, "isActive"); // boolean

// keyof with index signatures
interface StringMap {
  [key: string]: number;
}
type StringMapKeys = keyof StringMap;  // string | number (because numeric keys are also valid)

// keyof with arrays
type ArrayKeys = keyof string[];
// "length" | "push" | "pop" | "map" | ... | number

5. Mapped types

Mapped types create new types by transforming each property of an existing type:

// Basic syntax: iterate over keys and transform
type ReadonlyType<T> = {
  readonly [K in keyof T]: T[K];
};

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

type ReadonlyUser = ReadonlyType<User>;
// { readonly id: number; readonly name: string; readonly email: string }

// Make all properties optional
type OptionalType<T> = {
  [K in keyof T]?: T[K];
};

type OptionalUser = OptionalType<User>;
// { id?: number; name?: string; email?: string }

// Transform property types
type Stringify<T> = {
  [K in keyof T]: string;
};

type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }

// Nullable version
type NullableProps<T> = {
  [K in keyof T]: T[K] | null;
};

type NullableUser = NullableProps<User>;
// { id: number | null; name: string | null; email: string | null }

// Boolean flags from an interface
type Flags<T> = {
  [K in keyof T]: boolean;
};

type UserFlags = Flags<User>;
// { id: boolean; name: boolean; email: boolean }
// Useful for tracking which fields have been modified

6. Utility types — TypeScript's built-in type transformers

TypeScript provides several built-in utility types that solve common patterns:

Partial<T> — make all properties optional

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

// Partial<User> = { id?: number; name?: string; email?: string }

function updateUser(id: number, updates: Partial<User>): User {
  const existing = getUser(id);
  return { ...existing, ...updates };
}

updateUser(1, { name: "New Name" });           // OK — only updating name
updateUser(1, { email: "new@example.com" });   // OK — only updating email
updateUser(1, {});                             // OK — no updates

Required<T> — make all properties required

interface Config {
  host?: string;
  port?: number;
  ssl?: boolean;
}

type RequiredConfig = Required<Config>;
// { host: string; port: number; ssl: boolean } — all required now

function startServer(config: RequiredConfig): void {
  // All properties guaranteed to exist
  console.log(`Starting on ${config.host}:${config.port}`);
}

Pick<T, K> — select specific properties

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

// Use case: API response that excludes sensitive data
function getPublicProfile(user: User): PublicUser {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
  };
}

Omit<T, K> — exclude specific properties

type UserWithoutPassword = Omit<User, "password">;
// { id: number; name: string; email: string; createdAt: Date }

// Use case: creating a new user (no id yet)
type CreateUserInput = Omit<User, "id" | "createdAt">;
// { name: string; email: string; password: string }

function createUser(input: CreateUserInput): User {
  return {
    id: generateId(),
    createdAt: new Date(),
    ...input,
  };
}

Record<K, V> — object with typed keys and values

// String keys, number values
type ScoreBoard = Record<string, number>;

const scores: ScoreBoard = {
  alice: 95,
  bob: 87,
  charlie: 92,
};

// Literal union keys
type Role = "admin" | "user" | "guest";

type RolePermissions = Record<Role, string[]>;

const permissions: RolePermissions = {
  admin: ["read", "write", "delete", "manage"],
  user: ["read", "write"],
  guest: ["read"],
};

// Combining Record with interfaces
type UserMap = Record<string, User>;

const users: UserMap = {
  "user-1": { id: 1, name: "Alice", email: "alice@example.com", password: "...", createdAt: new Date() },
};

Readonly<T> — make all properties readonly

interface AppState {
  user: User | null;
  theme: "light" | "dark";
  notifications: string[];
}

type FrozenState = Readonly<AppState>;

const state: FrozenState = {
  user: null,
  theme: "dark",
  notifications: [],
};

// state.theme = "light";  // Error: read-only
// state.user = someUser;  // Error: read-only

Utility types summary table

UtilityEffectExample
Partial<T>All properties optionalPartial<User>{ name?: string; ... }
Required<T>All properties requiredRequired<Config> → no optional fields
Pick<T, K>Keep only listed propertiesPick<User, "id" | "name">
Omit<T, K>Remove listed propertiesOmit<User, "password">
Record<K, V>Object type from key/value typesRecord<string, number>
Readonly<T>All properties readonlyReadonly<Config>
ReturnType<F>Extract function return typeReturnType<typeof fetchUser>
Parameters<F>Extract function parameter typesParameters<typeof greet>
NonNullable<T>Remove null/undefinedNonNullable<string | null>string
Exclude<T, U>Remove types from unionExclude<"a" | "b" | "c", "a">"b" | "c"
Extract<T, U>Keep types in unionExtract<"a" | "b" | "c", "a" | "b">"a" | "b"

7. Default type parameters

Generic types can have default values, just like function parameters:

// Default T to string
interface Container<T = string> {
  value: T;
}

const strContainer: Container = { value: "hello" };         // T defaults to string
const numContainer: Container<number> = { value: 42 };      // T is number

// Default in type aliases
type ApiResponse<T = unknown> = {
  data: T;
  status: number;
};

// No type argument needed:
const response: ApiResponse = { data: "anything", status: 200 };

// Explicit type argument:
const userResponse: ApiResponse<User> = { data: { id: 1, name: "Alice" }, status: 200 };

// Default with constraints
type Collection<T extends object = Record<string, unknown>> = {
  items: T[];
  count: number;
};

8. Real-world examples

Generic form handler

interface FormConfig<T> {
  initialValues: T;
  validate: (values: T) => Partial<Record<keyof T, string>>;
  onSubmit: (values: T) => Promise<void>;
}

interface FormState<T> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  isSubmitting: boolean;
  isDirty: boolean;
}

function createForm<T extends Record<string, unknown>>(config: FormConfig<T>): FormState<T> {
  return {
    values: { ...config.initialValues },
    errors: {},
    isSubmitting: false,
    isDirty: false,
  };
}

// Usage:
interface LoginForm {
  email: string;
  password: string;
}

const loginForm = createForm<LoginForm>({
  initialValues: { email: "", password: "" },
  validate: (values) => {
    const errors: Partial<Record<keyof LoginForm, string>> = {};
    if (!values.email.includes("@")) errors.email = "Invalid email";
    if (values.password.length < 8) errors.password = "Too short";
    return errors;
  },
  onSubmit: async (values) => {
    await fetch("/api/login", { method: "POST", body: JSON.stringify(values) });
  },
});

Typed API response wrapper

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

interface ApiConfig {
  baseUrl: string;
  headers?: Record<string, string>;
}

function createApiClient(config: ApiConfig) {
  async function request<T>(method: HttpMethod, path: string, body?: unknown): Promise<T> {
    const response = await fetch(`${config.baseUrl}${path}`, {
      method,
      headers: {
        "Content-Type": "application/json",
        ...config.headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }

  return {
    get: <T>(path: string) => request<T>("GET", path),
    post: <T>(path: string, body: unknown) => request<T>("POST", path, body),
    put: <T>(path: string, body: unknown) => request<T>("PUT", path, body),
    delete: <T>(path: string) => request<T>("DELETE", path),
  };
}

// Usage:
const api = createApiClient({ baseUrl: "https://api.example.com" });

const users = await api.get<User[]>("/users");       // User[]
const newUser = await api.post<User>("/users", {      // User
  name: "Alice",
  email: "alice@example.com",
});

Repository pattern

interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(item: Omit<T, "id">): Promise<T>;
  update(id: string, updates: Partial<Omit<T, "id">>): Promise<T>;
  delete(id: string): Promise<void>;
}

// Concrete implementation:
interface User { id: string; name: string; email: string; }

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

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

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

  async create(item: Omit<User, "id">): Promise<User> {
    const user: User = { id: crypto.randomUUID(), ...item };
    this.users.push(user);
    return user;
  }

  async update(id: string, updates: Partial<Omit<User, "id">>): 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], ...updates };
    return this.users[index];
  }

  async delete(id: string): Promise<void> {
    this.users = this.users.filter(u => u.id !== id);
  }
}

Key takeaways

  1. Generic interfaces (Box<T>) and generic type aliases (type Result<T>) create reusable type structures.
  2. Constraints (T extends HasId) restrict which types a generic accepts.
  3. keyof produces a union of all property names of a type — essential for type-safe property access.
  4. Mapped types ({ [K in keyof T]: ... }) transform existing types property by property.
  5. Utility types (Partial, Pick, Omit, Record, Readonly) are built-in mapped types for common patterns.
  6. Default type parameters (<T = string>) provide fallback types when no argument is given.

Explain-It Challenge

Explain without notes:

  1. What does <T extends { length: number }> mean as a constraint?
  2. What is the difference between Pick<T, K> and Omit<T, K>?
  3. How does keyof work, and why is it useful with generics?

Navigation: ← 1.25 Overview · 1.25.i — TypeScript Tooling →