TypeScript – Types, Interfaces & Generics
TypeScript's type system is its most powerful feature. Used correctly, it eliminates entire classes of runtime errors at compile time and makes code self-documenting. This page covers how to use types, interfaces, generics, and the advanced type system features that distinguish professional TypeScript from beginner TypeScript.
any vs unknown — The Most Important Distinction
any opts out of the type system entirely. unknown is the safe alternative — it forces you to narrow the type before using the value:
// ❌ any — disables all type safety
function processInput(data: any) {
console.log(data.name.toUpperCase()); // no compile error
// 💥 crashes at runtime if data.name is not a string
}
// ✅ unknown — forces narrowing before use
function processInput(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
const { name } = data as { name: unknown };
if (typeof name === 'string') {
console.log(name.toUpperCase()); // safe
}
}
}
// ✅ Even better — Zod validates and narrows simultaneously
import { z } from 'zod';
const InputSchema = z.object({ name: z.string() });
function processInput(data: unknown) {
const parsed = InputSchema.parse(data);
console.log(parsed.name.toUpperCase()); // fully type-safe
}
Use unknown for: function parameters accepting dynamic input, API responses, JSON.parse results, catch clause errors.
Type Assertions — Use Sparingly
Type assertions tell the compiler "trust me". They bypass type checking. Use only when you have runtime knowledge the compiler cannot infer:
// ✅ Acceptable — DOM element with known type from HTML structure
const canvas = document.getElementById('chart') as HTMLCanvasElement;
// ✅ Acceptable — after manual validation
function parseConfig(raw: unknown): Config {
if (!isValidConfig(raw)) throw new Error('Invalid config');
return raw as Config; // safe — isValidConfig narrowed it
}
// ❌ Using as to silence a type error — this is a bug, not a fix
const user = getUser() as User; // crashes at runtime if null
const id = (event.target as any).value; // as any kills all safety
Discriminated Unions — Modelling State
A shared literal field acts as the discriminant — TypeScript narrows the full type automatically when you check it:
// ✅ Every state is explicit and exhaustive
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function renderOrderState(state: FetchState<Order[]>) {
switch (state.status) {
case 'idle': return <EmptyState />;
case 'loading': return <Spinner />;
case 'success': return <OrderList orders={state.data} />; // data exists
case 'error': return <ErrorMessage error={state.error} />;
}
}
// ✅ API result pattern — used throughout the codebase
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: { code: string; message: string } };
const result = await createOrder(request);
if (result.success) {
console.log(result.data.id); // TypeScript: data exists
} else {
console.error(result.error.code); // TypeScript: error exists
}
Utility Types
Use built-in utility types instead of rewriting types manually:
interface User {
id: string;
email: string;
password: string;
firstName: string;
lastName: string;
role: 'admin' | 'user';
createdAt: Date;
}
type UpdateUserRequest = Partial<User>; // all properties optional
type RequiredUser = Required<User>; // all properties required
type ImmutableUser = Readonly<User>; // all properties readonly
type PublicUser = Pick<User, 'id' | 'firstName' | 'lastName' | 'role'>;
type UserWithoutPassword = Omit<User, 'password'>;
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>;
type RolePermissions = Record<UserRole, Permission[]>;
type DefinitelyUser = NonNullable<User | null | undefined>; // = User
type OrderServiceReturn = ReturnType<typeof OrderService.createOrder>;
type CreateOrderParams = Parameters<typeof OrderService.createOrder>;
type OrderData = Awaited<ReturnType<typeof fetchOrder>>; // = Order
Extract<T, U> and Exclude<T, U>
Use Extract to keep only the union members assignable to U. Use Exclude to remove them:
type Status = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
// Extract — keep members assignable to U
type ActiveStatus = Extract<Status, 'confirmed' | 'shipped' | 'delivered'>;
// = 'confirmed' | 'shipped' | 'delivered'
type TerminalStatus = Extract<Status, 'delivered' | 'cancelled'>;
// = 'delivered' | 'cancelled'
// Exclude — remove members assignable to U
type NonTerminalStatus = Exclude<Status, 'delivered' | 'cancelled'>;
// = 'pending' | 'confirmed' | 'shipped'
// ✅ Practical use — filter a union of event types
type OrderEvent =
| { type: 'order.created'; orderId: string }
| { type: 'order.paid'; orderId: string; amount: number }
| { type: 'user.created'; userId: string };
type OnlyOrderEvents = Extract<OrderEvent, { type: `order.${string}` }>;
// = { type: 'order.created'; ... } | { type: 'order.paid'; ... }
// ✅ Exclude null/undefined from a union (same as NonNullable)
type StringOnly = Exclude<string | null | undefined, null | undefined>; // = string
InstanceType<T> and ConstructorParameters<T>
// InstanceType<T> — extract the type that `new T()` produces
class OrderService {
constructor(
private repository: OrderRepository,
private logger: Logger,
) {}
}
type OrderServiceInstance = InstanceType<typeof OrderService>;
// = OrderService
// ✅ Useful for factory functions that return class instances
function createService<T extends new (...args: unknown[]) => unknown>(
Ctor: T,
): InstanceType<T> {
return new Ctor() as InstanceType<T>;
}
// ConstructorParameters<T> — extract the constructor parameter types as a tuple
type OrderServiceArgs = ConstructorParameters<typeof OrderService>;
// = [repository: OrderRepository, logger: Logger]
// ✅ Useful for forwarding constructor arguments
function withLogger<T extends new (...args: unknown[]) => unknown>(
Ctor: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
console.log(`Creating ${Ctor.name}`);
return new Ctor(...args) as InstanceType<T>;
}
Combining Utility Types
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: OrderStatus;
createdAt: Date;
updatedAt: Date;
}
type CreateOrderRequest = Pick<Order, 'userId' | 'items'>;
type OrderResponse = Omit<Order, 'updatedAt'>;
type UpdateOrderRequest = Partial<Omit<Order, 'id' | 'createdAt' | 'updatedAt'>>;
Branded / Opaque Types
Branded types use a phantom type field to make structurally identical types incompatible with each other at the type level. Without them, TypeScript's structural type system treats string and string as interchangeable — even when one is an OrderId and the other is a UserId.
// ✅ Brand utility type
type Brand<T, TBrand extends string> = T & { readonly _brand: TBrand };
// Create distinct named types from the same primitive
type OrderId = Brand<string, 'OrderId'>;
type UserId = Brand<string, 'UserId'>;
type Cents = Brand<number, 'Cents'>;
type EmailAddress = Brand<string, 'EmailAddress'>;
// ✅ Constructor functions validate and return the branded type
function toOrderId(id: string): OrderId {
if (!id.startsWith('ord_')) throw new Error(`Invalid OrderId: ${id}`);
return id as OrderId;
}
function toCents(amount: number): Cents {
if (!Number.isInteger(amount) || amount < 0) {
throw new Error(`Amount must be a non-negative integer in cents: ${amount}`);
}
return amount as Cents;
}
// ✅ Function signatures now prevent argument transposition errors
async function getOrder(orderId: OrderId, userId: UserId): Promise<Order> {
// ...
}
const orderId = toOrderId('ord_123');
const userId = toUserId('usr_456');
getOrder(orderId, userId); // ✅
getOrder(userId, orderId); // ❌ Compile error: Argument of type 'UserId' is not
// assignable to parameter of type 'OrderId'
// ✅ Without branded types, TypeScript would allow this — a common source of bugs
const rawOrderId = 'ord_123';
const rawUserId = 'usr_456';
getOrder(rawUserId, rawOrderId); // No error! Both are just string
Branded types are especially valuable for:
- ID parameters — preventing
orderIdanduserIdtransposition - Money amounts — distinguishing cents from dollars, or EUR from USD
- Validated strings — email addresses, URLs, slugs that have been parsed and confirmed
- Sensitive values — marking a
stringas aHashedPasswordorRedactedToken
// ✅ Zod integration — create branded types at the validation boundary
import { z } from 'zod';
const OrderIdSchema = z.string().startsWith('ord_').brand<'OrderId'>();
const UserIdSchema = z.string().startsWith('usr_').brand<'UserId'>();
const CentsSchema = z.number().int().nonnegative().brand<'Cents'>();
type OrderId = z.infer<typeof OrderIdSchema>; // Brand<string, 'OrderId'>
type Cents = z.infer<typeof CentsSchema>;
// The Zod parse validates at runtime AND brands the type at compile time
const orderId = OrderIdSchema.parse(req.params.orderId); // OrderId — not plain string
Generics — Writing Reusable Type-Safe Code
// ✅ Generic function
function getFirstItem<T>(array: T[]): T | undefined {
return array[0];
}
// ✅ Generic with constraint — T must have an id property
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// ✅ Generic repository interface
interface Repository<TEntity, TId = string> {
findById(id: TId): Promise<TEntity | null>;
findAll(filter?: Partial<TEntity>): Promise<TEntity[]>;
save(entity: TEntity): Promise<TEntity>;
delete(id: TId): Promise<void>;
}
// ✅ Typed API fetch wrapper
async function fetchApi<TData>(url: string): Promise<ApiResult<TData>> {
const response = await fetch(url);
if (!response.ok) return { success: false, error: await response.json() };
const data = await response.json() as TData;
return { success: true, data };
}
const result = await fetchApi<Order[]>('/api/v1/orders');
if (result.success) {
result.data.forEach(order => console.log(order.id)); // Order[], not any
}
const Type Parameters (TypeScript 5.0+)
const type parameters preserve literal types in generic functions, like applying as const automatically to the argument:
// Without const — types are widened to string[]
function createTuple<T extends string[]>(...args: T): T {
return args;
}
const t1 = createTuple('a', 'b', 'c');
// type: string[] — literal types lost
// ✅ With const — literal types are preserved
function createTuple<const T extends string[]>(...args: T): T {
return args;
}
const t2 = createTuple('a', 'b', 'c');
// type: readonly ['a', 'b', 'c'] — exact literals
// ✅ Practical use — route builder with literal path types
function defineRoutes<const T extends Record<string, string>>(routes: T): T {
return routes;
}
const routes = defineRoutes({
orders: '/api/v1/orders',
users: '/api/v1/users',
payments: '/api/v1/payments',
});
type RouteName = keyof typeof routes;
// type RouteName = 'orders' | 'users' | 'payments' — exact literals, not string
Recursive Types
TypeScript supports recursive type definitions. Use them to model tree structures, nested configurations, and JSON-like data:
// ✅ JSON-compatible type
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
// ✅ Tree node
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
// ✅ Nested configuration
type NestedConfig = {
[key: string]: string | number | boolean | NestedConfig;
};
// ✅ Deep partial — useful for deeply nested patch operations
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// ✅ Deep readonly — immutable nested objects
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const config: DeepReadonly<AppConfig> = loadConfig();
config.database.host = 'new'; // ❌ Compile error: readonly
keyof and typeof
// keyof — union of an object's key names
type ConfigKey = keyof Config; // 'host' | 'port' | 'debug'
// ✅ Type-safe property accessor
function getConfigValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const host = getConfigValue(config, 'host'); // type: string
const port = getConfigValue(config, 'port'); // type: number
// typeof — capture the type of a value at compile time
const defaultConfig = { host: 'localhost', port: 3000, debug: false };
type DefaultConfig = typeof defaultConfig;
// ✅ Extract type from as const object
const OrderStatus = { Pending: 'pending', Confirmed: 'confirmed' } as const;
type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];
// = 'pending' | 'confirmed'
as const — Literal Type Inference
// ✅ Exact literal types
const colours = ['red', 'green', 'blue'] as const;
type Colour = typeof colours[number]; // 'red' | 'green' | 'blue'
const API_ROUTES = {
orders: '/api/v1/orders',
users: '/api/v1/users',
payments: '/api/v1/payments',
} as const;
type ApiRoute = typeof API_ROUTES[keyof typeof API_ROUTES];
function fetchRoute(route: ApiRoute) {}
fetchRoute('/api/v1/orders'); // ✅
fetchRoute('/api/v1/unknown'); // ❌ compile error
Template Literal Types
// ✅ Precise string-based types
type OrderEvent = `order.${'created' | 'updated' | 'cancelled' | 'delivered'}`;
// = 'order.created' | 'order.updated' | 'order.cancelled' | 'order.delivered'
function subscribeToOrderEvents(event: OrderEvent, handler: () => void) {}
subscribeToOrderEvents('order.created', handleCreated); // ✅
subscribeToOrderEvents('order.shipped', handleShipped); // ❌ compile error
// ✅ Route string types
type ApiEndpoint = `/${'v1' | 'v2'}/${'orders' | 'users' | 'payments'}`;
Template Literal Intrinsic Types
TypeScript includes built-in utility types for string case manipulation at the type level:
type Status = 'pending' | 'confirmed' | 'cancelled';
type UpperStatus = Uppercase<Status>; // 'PENDING' | 'CONFIRMED' | 'CANCELLED'
type LowerStatus = Lowercase<'PENDING'>; // 'pending'
type CapStatus = Capitalize<Status>; // 'Pending' | 'Confirmed' | 'Cancelled'
type UncapStatus = Uncapitalize<'Pending'>; // 'pending'
// ✅ Useful for generating event names from model names
type ModelName = 'order' | 'user' | 'payment';
type CrudEvent<T extends string> =
| `${T}.created`
| `${T}.updated`
| `${T}.deleted`;
type OrderCrudEvents = CrudEvent<'order'>;
// = 'order.created' | 'order.updated' | 'order.deleted'
// ✅ Generate handler method names from event names
type HandlerName<T extends string> = `handle${Capitalize<T>}`;
type OrderHandler = HandlerName<'created' | 'updated'>;
// = 'handleCreated' | 'handleUpdated'
Mapped Types
// ✅ Make specific properties required (partial by others)
type Complete<T> = { [K in keyof T]-?: NonNullable<T[K]> };
// ✅ Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// ✅ Event handler map derived from event union
type EventHandlers = {
[K in OrderEvent]: (event: { type: K; payload: Order }) => void;
};
never — Exhaustiveness Checking
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
function getStatusLabel(status: OrderStatus): string {
switch (status) {
case 'pending': return 'Awaiting confirmation';
case 'confirmed': return 'Order confirmed';
case 'shipped': return 'On the way';
case 'delivered': return 'Delivered';
case 'cancelled': return 'Cancelled';
default:
// Adding a new status without updating this function is a compile error
const _exhaustiveCheck: never = status;
return _exhaustiveCheck;
}
}
// If 'refunded' is added to OrderStatus:
// Type '"refunded"' is not assignable to type 'never' ❌
Conditional Types
// ✅ Unwrap array element type
type ElementOf<T> = T extends (infer U)[] ? U : never;
type OrderElement = ElementOf<Order[]>; // = Order
// ✅ Unwrap Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type OrderData = UnwrapPromise<Promise<Order>>; // = Order
// ✅ Conditional props — require field B when field A is true
type AuthProps<T extends boolean> = {
isAuthenticated: T;
} & (T extends true ? { user: User } : { user?: never });
infer — Type Extraction in Conditional Types
infer captures a type variable within a conditional type. It is how TypeScript extracts the "inner" type of a generic wrapper without knowing the wrapper up front:
// ✅ Extract the first argument type of any function
type FirstArg<T> = T extends (first: infer F, ...rest: unknown[]) => unknown ? F : never;
type GetUserFirstArg = FirstArg<typeof getUser>;
// If getUser is (id: string) => Promise<User>, then FirstArg = string
// ✅ Unwrap nested promises
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Result = DeepAwaited<Promise<Promise<Order>>>; // = Order
// ✅ Extract the value type from a Map
type MapValue<T> = T extends Map<unknown, infer V> ? V : never;
type UserMapValue = MapValue<Map<string, User>>; // = User
// ✅ Infer return type from an async function (same as Awaited<ReturnType<T>>)
type AsyncReturn<T extends (...args: unknown[]) => Promise<unknown>> =
T extends (...args: unknown[]) => Promise<infer R> ? R : never;
type FetchOrderReturn = AsyncReturn<typeof fetchOrder>; // = Order
satisfies Operator (TypeScript 4.9+)
satisfies validates a value against a type without widening the inferred type:
type ColourMap = Record<string, string | string[]>;
// ✅ satisfies — validates type, keeps specific literal types
const palette = {
red: '#FF0000',
green: ['#00FF00', '#00CC00', '#009900'],
} satisfies ColourMap;
palette.red.toUpperCase(); // ✅ — TypeScript knows red is string
palette.green.map(c => c); // ✅ — TypeScript knows green is string[]
// With type annotation — types are widened
const palette2: ColourMap = { red: '#FF0000', green: ['#00FF00'] };
palette2.red.toUpperCase(); // ❌ Error: string | string[] has no toUpperCase
Type Guards
// ✅ Type predicate function
function isOrder(value: unknown): value is Order {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as Order).id === 'string' &&
'status' in value
);
}
// ✅ Assertion function — throws or narrows
function assertIsOrder(value: unknown): asserts value is Order {
if (!isOrder(value)) {
throw new TypeError(`Expected Order, got ${typeof value}`);
}
}
const data = await fetchFromApi();
if (isOrder(data)) {
console.log(data.status); // TypeScript: data is Order
}
assertIsOrder(data);
console.log(data.status); // TypeScript: data is Order after assertion