Skip to content

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 orderId and userId transposition
  • 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 string as a HashedPassword or RedactedToken
// ✅ 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