Skip to main content

🧱 Part 17 — Advanced TypeScript Patterns (Branded Types, Results, Exhaustive Checks) 🧠


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

Popular posts from this blog

🌟 Dot net Microservices interview questions

Here is a comprehensive list of 200 .NET microservices coding questions covering all core microservices concepts and cross-cutting concerns relevant for designing, building, deploying, and maintaining .NET-based distributed systems. 🧩 A. Microservices Fundamentals (20) Build a microservice in .NET 8 that exposes a simple CRUD API. Implement communication between two microservices using REST. How would you design microservices for an e-commerce application? Create a microservice that handles user registration and login. How do you isolate domain logic in a microservice? How to apply the "Single Responsibility Principle" in microservices? Design a service registry/discovery mechanism using custom middleware. Implement a service that handles file uploads and metadata separately. Build a stateless microservice and explain its benefits. Implement health check endpoints in .NET 8. Demonstrate versioning in a microservice API. Add Swagger/OpenAPI support to your m...

⚡ Part 1: Introduction to Generics in C#

🌍 Why Do We Need Generics? Imagine you want to create a stack (like a pile of books 📚): You can push items on top You can pop items off the top If we write a stack for integers : public class IntStack { private int[] items = new int[10]; private int index = 0; public void Push(int item) => items[index++] = item; public int Pop() => items[--index]; } 👉 Problem: This only works for int . What if we want a string stack ? Or a Customer stack ? We’d have to duplicate code for every type. 😢 ✅ Solution: Generics Generics let us create type-safe reusable code without duplication. We can say: “I don’t care what type it is yet — I’ll decide later.” 1) Generic Classes Here’s a generic stack : // Generic class "Stack<T>" // The <T> is a placeholder for any type public class Stack<T> { private T[] items = new T[10]; // Array of type T private int index = 0; // Push adds an item of type T public void P...

🚪 Part 9: API Gateway for .NET 8 Microservices (Ocelot & YARP)

Once you have multiple microservices (Products, Orders, Payments…), exposing each one directly to clients gets messy: Different base URLs Duplicated auth logic No unified rate limiting / caching Hard to evolve routes or aggregate data 👉 Enter the API Gateway — your single front door for all microservices. An API Gateway handles: ✅ Routing & path rewriting ✅ Load balancing, retries, circuit breakers ✅ Authentication & Authorization (JWT, OAuth2) ✅ Rate limiting & caching ✅ Aggregation (compose results from multiple services) In this post we’ll implement two strong options: Ocelot → config-driven, mature, DevOps-friendly YARP (Yet Another Reverse Proxy) → Microsoft’s code-first, extensible gateway ⚖️ Ocelot vs YARP — When to Choose Ocelot → JSON config, minimal C#, built-in QoS (rate limit, circuit breaker). Perfect for teams that like DevOps config-as-code. YARP → full C# control, middleware-friendly, can embed into broader apps (e.g. add dashb...