When your app grows, types become your safety net. Here are advanced—but approachable—TypeScript patterns that prevent entire classes of bugs. Every snippet is commented and copy‑paste ready.
1) Branded (Opaque) Types — prevent mixing IDs/units
Avoid passing a ProjectId where a TaskId is expected.
// src/types/brand.ts
// Create an opaque subtype of T (compile-time only)
export type Brand<T, B extends string> = T & { readonly __brand: B };
export type TaskId = Brand<string, 'TaskId'>;
export type ProjectId = Brand<string, 'ProjectId'>;
export const TaskId = (s: string): TaskId => s as TaskId;
export const ProjectId = (s: string): ProjectId => s as ProjectId;
// Usage
function loadTask(id: TaskId) {/* impl */}
// loadTask('123'); // ❌ plain string not allowed
loadTask(TaskId('123')); // ✅ branded
Brands exist only at compile time; runtime values are plain strings.
2) Discriminated Unions + Exhaustive Checks
Model finite states and force every case to be handled.
// src/types/loadState.ts
export type Loading = { kind: 'loading' };
export type Ready<T> = { kind: 'ready'; data: T };
export type Failure = { kind: 'failure'; message: string };
export type LoadState<T> = Loading | Ready<T> | Failure;
export function renderState<T>(s: LoadState<T>) {
switch (s.kind) {
case 'loading':
return 'Loading…';
case 'ready':
return `Count: ${Array.isArray(s.data) ? s.data.length : 1}`;
case 'failure':
return `Error: ${s.message}`;
default:
return exhaustive(s); // ⬅️ TS error if a case is missing
}
}
export function exhaustive(x: never): never { throw new Error('Unreachable: ' + String(x)); }
3) Type Predicates — turn runtime checks into type info
// src/types/predicates.ts
export type TaskDTO = { id: string; title: string; done: boolean; created_at: string };
export function isTaskDTO(x: unknown): x is TaskDTO {
if (typeof x !== 'object' || x === null) return false;
const o = x as Record<string, unknown>;
return (
typeof o.id === 'string' &&
typeof o.title === 'string' &&
typeof o.done === 'boolean' &&
typeof o.created_at === 'string'
);
}
// Usage with unknown JSON
defensive(JSON.parse('{"id":"1","title":"Hi","done":false,"created_at":"2025-01-01"}'));
function defensive(data: unknown) {
if (!isTaskDTO(data)) throw new Error('Bad payload');
// data is now TaskDTO here
}
Prefer Zod when you also want runtime validation + inferred types. Predicates are the lightweight option.
4) Result Type — explicit success/failure (no hidden throws)
// src/types/result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E = string> = { ok: false; error: E };
export type Result<T, E = string> = Ok<T> | Err<E>;
export const Ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const Err = <E>(error: E): Err<E> => ({ ok: false, error });
// Promise wrapper
export async function wrap<T>(p: Promise<T>): Promise<Result<T, unknown>> {
try { return Ok(await p); } catch (e) { return Err(e); }
}
Service example with Axios:
// src/services/tasks.try.ts
import { Ok, Err, type Result } from '@/types/result';
import { http } from '@/lib/api/http';
import { isTaskDTO } from '@/types/predicates';
export async function tryGetTask(id: string): Promise<Result<{ id: string; title: string }>> {
try {
const res = await http.get('/tasks/' + id);
if (!isTaskDTO(res.data)) return Err('Invalid payload');
return Ok({ id: res.data.id, title: res.data.title });
} catch (e: any) {
return Err(e?.message ?? 'Network error');
}
}
5) Option Type — explicit “maybe” without null soup
// src/types/option.ts
export type None = { _tag: 'None' };
export type Some<T> = { _tag: 'Some'; value: T };
export type Option<T> = None | Some<T>;
export const None: None = { _tag: 'None' };
export const Some = <T>(value: T): Some<T> => ({ _tag: 'Some', value });
export const map = <A, B>(o: Option<A>, f: (a: A) => B): Option<B> => o._tag === 'Some' ? Some(f(o.value)) : None;
export const getOrElse = <A>(o: Option<A>, fallback: A): A => o._tag === 'Some' ? o.value : fallback;
6) NonEmptyArray — safe head/tail
// src/types/nonEmpty.ts
export type NonEmptyArray<T> = [T, ...T[]];
export const head = <T>(nea: NonEmptyArray<T>) => nea[0];
export const tail = <T>(nea: NonEmptyArray<T>) => nea.slice(1);
// from normal array (returns Option)
import { Option, None, Some } from './option';
export function fromArray<T>(arr: T[]): Option<NonEmptyArray<T>> {
return arr.length === 0 ? None : Some(arr as NonEmptyArray<T>);
}
7) Deep Readonly — prevent accidental mutation
// src/types/readonly.ts
export type ReadonlyDeep<T> = T extends (infer U)[]
? ReadonlyArray<ReadonlyDeep<U>>
: T extends object
? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }
: T;
// Example
const t: ReadonlyDeep<{ id: string; tags: string[] }> = { id: '1', tags: ['work'] };
// t.tags.push('x'); // ❌ cannot push on ReadonlyArray
8) Safer DTO → Domain mapping with asserts
// src/lib/api/ensure.ts
export function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
// src/lib/api/tasks.map.ts
import { assert } from './ensure';
import { TaskId } from '@/types/brand';
import type { TaskDTO } from '@/types/predicates';
export type Task = { id: ReturnType<typeof TaskId>; title: string; done: boolean; createdAt: Date };
export function toDomain(d: TaskDTO): Task {
assert(typeof d.id === 'string', 'id string');
assert(typeof d.title === 'string', 'title string');
assert(typeof d.done === 'boolean', 'done boolean');
assert(!Number.isNaN(Date.parse(d.created_at)), 'created_at date');
return {
id: TaskId(d.id),
title: d.title,
done: d.done,
createdAt: new Date(d.created_at),
};
}
9) Utility mapped types — expressive APIs
// src/types/utils.ts
// Require at least one key from T
export type RequireAtLeastOne<T, K extends keyof T = keyof T> =
K extends keyof T ? (Required<Pick<T, K>> & Omit<T, K>) : never;
// Example
type TaskPatch = { title?: string; done?: boolean };
function updateTask(id: string, patch: RequireAtLeastOne<TaskPatch>) {/* impl */}
// updateTask('1', {}); // ❌ must include at least one field
updateTask('1', { done: true }); // ✅
10) Generic function overload with literal inference
// src/utils/pick.ts
export function pick<T, const K extends readonly (keyof T)[]>(obj: T, keys: K): Pick<T, K[number]>;
export function pick(obj: any, keys: readonly string[]) {
return Object.fromEntries(keys.map((k) => [k, obj[k as keyof typeof obj]]));
}
const o = { id: '1', title: 'A', done: false };
const p = pick(o, ['id', 'done'] as const); // type → { id: string; done: boolean }
11) Putting it together — safer service signature
// src/services/tasks.service.ts
import { Ok, Err, type Result } from '@/types/result';
import { isTaskDTO } from '@/types/predicates';
import { toDomain, type Task } from '@/lib/api/tasks.map';
import { http } from '@/lib/api/http';
import { TaskId } from '@/types/brand';
export async function fetchTask(id: ReturnType<typeof TaskId>): Promise<Result<Task>> {
try {
const res = await http.get('/tasks/' + id);
const data = res.data;
if (!isTaskDTO(data)) return Err('Invalid payload');
return Ok(toDomain(data));
} catch (e: any) {
return Err(e?.message ?? 'Network failure');
}
}
✅ Wrap‑Up
You now have high‑leverage TS tools:
- Brands prevent ID/unit mixups
- Exhaustive unions stop missing states
- Predicates & asserts upgrade runtime checks into type guarantees
- Result/Option model failure and absence explicitly
- NonEmptyArray/ReadonlyDeep make collections safer
Use them at boundaries (API, reducers, services) for maximum impact.
Comments
Post a Comment