Episode 1 — Fundamentals / 1.25 — TypeScript Essentials
1.25.h — Generic Interfaces and Constraints
In one sentence: Generic interfaces, constraints (
extends), thekeyofoperator, mapped types, and utility types likePartial<T>,Pick<T, K>, andOmit<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
| Utility | Effect | Example |
|---|---|---|
Partial<T> | All properties optional | Partial<User> → { name?: string; ... } |
Required<T> | All properties required | Required<Config> → no optional fields |
Pick<T, K> | Keep only listed properties | Pick<User, "id" | "name"> |
Omit<T, K> | Remove listed properties | Omit<User, "password"> |
Record<K, V> | Object type from key/value types | Record<string, number> |
Readonly<T> | All properties readonly | Readonly<Config> |
ReturnType<F> | Extract function return type | ReturnType<typeof fetchUser> |
Parameters<F> | Extract function parameter types | Parameters<typeof greet> |
NonNullable<T> | Remove null/undefined | NonNullable<string | null> → string |
Exclude<T, U> | Remove types from union | Exclude<"a" | "b" | "c", "a"> → "b" | "c" |
Extract<T, U> | Keep types in union | Extract<"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
- Generic interfaces (
Box<T>) and generic type aliases (type Result<T>) create reusable type structures. - Constraints (
T extends HasId) restrict which types a generic accepts. keyofproduces a union of all property names of a type — essential for type-safe property access.- Mapped types (
{ [K in keyof T]: ... }) transform existing types property by property. - Utility types (
Partial,Pick,Omit,Record,Readonly) are built-in mapped types for common patterns. - Default type parameters (
<T = string>) provide fallback types when no argument is given.
Explain-It Challenge
Explain without notes:
- What does
<T extends { length: number }>mean as a constraint? - What is the difference between
Pick<T, K>andOmit<T, K>? - How does
keyofwork, and why is it useful with generics?
Navigation: ← 1.25 Overview · 1.25.i — TypeScript Tooling →