DEV Community

Sensational-DEV
Sensational-DEV

Posted on

3 TypeScript patterns I keep stealing from open-source codebases

Every few months I find a TypeScript pattern in someone's open-source repo that I instantly bring back to my own code. It's the cheapest form of getting better — read code that's been read by hundreds of contributors and find the patterns that survived.

Here are three I keep reaching for in 2026.

1. satisfies for catch-typos-at-compile-time configs

You know the routine — a config object that holds, say, all your route paths, or all your event names. You want autocomplete when you reference one, but you also want TS to scream if you fat-finger a key.

The old way:

type RouteKey = 'home' | 'blog' | 'profile';
type Routes = Record<RouteKey, string>;

export const routes: Routes = {
  home: '/',
  blog: '/blog',
  profile: '/profile',
};

routes.home; // type is `string` — autocomplete works, but you lose the literal
Enter fullscreen mode Exit fullscreen mode

The satisfies way:

export const routes = {
  home: '/',
  blog: '/blog',
  profile: '/profile',
} satisfies Record<string, string>;

routes.home; // type is the literal '/', not just string
Enter fullscreen mode Exit fullscreen mode

satisfies checks the shape but preserves the specific inferred types of every key and value. If I refer to routes.home, TS knows the value is '/', not just string. Useful when those literals drive other types downstream:

type Path = (typeof routes)[keyof typeof routes];
// Path is '/' | '/blog' | '/profile' — not string
Enter fullscreen mode Exit fullscreen mode

I stole this from a Remix codebase last year. It's in every project I touch now.

2. Branded types to stop ID confusion

Ever passed userId where postId was expected? TypeScript won't catch it, because both are strings.

function getUser(id: string) { /* ... */ }
function getPost(id: string) { /* ... */ }

const userId = 'u_abc';
const postId = 'p_xyz';

getUser(postId); // no error — the bug ships
Enter fullscreen mode Exit fullscreen mode

Brand them and the bug becomes unrepresentable:

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

function asUserId(s: string): UserId { return s as UserId; }
function asPostId(s: string): PostId { return s as PostId; }

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = asUserId('u_abc');
const postId = asPostId('p_xyz');

getUser(postId); // Error: 'PostId' is not assignable to 'UserId'
Enter fullscreen mode Exit fullscreen mode

The branding is a zero-cost lie — UserId is really just string at runtime — but the compiler treats them as different types. The asUserId helper is the only blessed entry point, so all the unsafe casting happens in one place you can audit.

I picked this up from a fintech repo where mixing up OrderId and TradeId would have been a five-figure mistake. For a side project it's overkill. For anything touching money or user data, I reach for it instantly.

3. Discriminated unions with a kind field

This isn't new, but I use it more than any other pattern on this list, so it belongs.

State that can be in one of several modes — loading / success / error, draft / published / archived, free / pro / enterprise — should be a discriminated union, not three optional booleans.

The painful way:

interface FetchState<T> {
  data?: T;
  error?: string;
  isLoading?: boolean;
}

if (state.data) {
  // might still be loading? unclear
  // might have a stale error? also unclear
}
Enter fullscreen mode Exit fullscreen mode

The clean way:

type FetchState<T> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: T }
  | { kind: 'error'; error: string };

function render(state: FetchState<User>) {
  switch (state.kind) {
    case 'idle':    return null;
    case 'loading': return <Spinner />;
    case 'success': return <Profile user={state.data} />;     // data is typed
    case 'error':   return <ErrorMessage msg={state.error} />; // error is typed
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside each case block, TS narrows the type for you. state.data exists in 'success' and nowhere else. You can't accidentally read it from 'loading' because it isn't on that variant.

The kind field name is convention. Some teams use type, some use status. Pick one, use it everywhere — switching the discriminator name across files makes every code review harder than it needs to be.

What I'd skip

Decorator-heavy patterns. Stage-3 decorators are stable in TS 5+, but every project I've used them in eventually hit a corner where they didn't compose well with the framework's lifecycle. I've gone back to plain functions and higher-order helpers and never missed the magic.

The meta lesson

Reading open-source isn't just for inspiration. It's professional development without the bootcamp tuition. Every pattern in this list survived hundreds of contributors making small calls — that's a stronger filter than my own taste alone.

If you haven't already, pick one TypeScript codebase you admire and read 30 minutes of source this week. Try to spot a pattern you don't already use. Steal it. Use it on Monday.

That's how you get better — one stolen pattern at a time.

Top comments (0)