^.^;
Back to Blog
Blog Post

TypeScript Patterns That Actually Make Code Better

TypeScript is more than just 'JavaScript with types.' Learn patterns with 3 levels of explanation: Like You're 3, Like You're My Boss, Like You're My Girlfriend. Discriminated Unions, Branded Types, Const Assertions, and more!

J
JG
Author
2025-11-23
Published
◆ ◆ ◆

TypeScript Patterns That Actually Make Code Better

TypeScript is more than just "JavaScript with types." When used properly, it can prevent entire classes of bugs before you even run your code.

Here are the patterns I use every day that have saved me countless hours of debugging.


#1. Discriminated Unions (Tagged Unions)

This is the most powerful pattern in TypeScript.

The Problem

You have interface SuccessResponse and interface ErrorResponse. Your API returns type ApiResponse = SuccessResponse | ErrorResponse.

// ❌ TypeScript doesn't know which one it is
interface SuccessResponse { 
  status: 'success'; 
  data: User[]; 
}

interface ErrorResponse { 
  status: 'error'; 
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
  // ❌ TypeScript doesn't know which one it is
  console.log(response.data); // Error: Property 'data' might not exist
}
👶

Explain Like I'm 3

Imagine you have two boxes: a "Happy Box" with toys inside, and a "Sad Box" that's empty. Someone hands you a box, but you don't know which one it is. If you try to grab a toy without checking first, you might reach into the empty box and get confused! TypeScript is saying "Hey, check which box it is first!"

💼

Explain Like You're My Boss

This code has a critical bug: we're trying to access response.data without verifying the response type first. In production, this causes runtime crashes when the API returns an error. The compiler can't help us because we haven't given it enough information to distinguish between success and error cases. This leads to defensive coding, try-catch blocks everywhere, and increased tech debt.

Bottom line: Without type safety here, we're shipping bugs to customers.

💕

Explain Like You're My Girlfriend

Okay so you know how you text me "are you home?" and I just reply "no"? And then you're like "okay but WHERE are you?" because my answer wasn't helpful? That's what this code is doing. The computer is asking "what kind of response is this?" and the code is like "idk could be either" and the computer is like "well I can't help you then!" So then the app crashes and I have to fix it at 2am instead of watching Netflix with you. This is why I'm always debugging. 😅

The Solution

Use a discriminant property to tell TypeScript which type it is:

// ✅ Discriminant property: status
interface SuccessResponse { 
  status: 'success'; 
  data: User[]; 
}

interface ErrorResponse { 
  status: 'error'; 
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
  if (response.status === 'success') { 
    // ✅ TypeScript knows it's SuccessResponse
    console.log(response.data); // No error!
  } else { 
    // ✅ TypeScript knows it's ErrorResponse
    console.log(response.error); // No error!
  }
}
👶

Explain Like I'm 3

Now we put a sticker on each box! The "Happy Box" gets a smiley face sticker (status: 'success'), and the "Sad Box" gets a frowny face sticker (status: 'error'). Before you reach inside, you check the sticker. If it's a smiley face, you know there are toys inside! If it's a frowny face, you know it's empty. No more surprises!

💼

Explain Like You're My Boss

By adding a discriminant property (status), we've enabled the TypeScript compiler to provide compile-time guarantees about data access. This eliminates an entire category of runtime errors, reduces QA cycles, and improves developer velocity. The status field acts as a type guard, allowing the compiler to narrow the union type and provide accurate autocomplete.

ROI: This pattern has prevented approximately 50+ production bugs in our cybersecurity lab platform, saving ~20 hours of debugging time per quarter.

💕

Explain Like You're My Girlfriend

Remember when we were organizing the closet and you labeled all the boxes? "Winter clothes", "Summer clothes", "Random stuff I'll never use but can't throw away"? And I was like "this is amazing, now I know what's in each box without opening it!" That's exactly what we're doing here. Each response gets a label (the status field), so the computer knows what's inside before trying to use it. No more guessing, no more crashes, no more me texting "I'll be home late, the app broke again." Also can we please throw away that box of random stuff? 😅💕

Real-world usage in my labs:

type AttackStage = 
  | { stage: 'idle' } 
  | { stage: 'scanning', progress: number } 
  | { stage: 'exploiting', target: string, progress: number } 
  | { stage: 'complete', results: ExploitResult[] };

function renderAttackStage(state: AttackStage) {
  switch (state.stage) {
    case 'idle': 
      return; // TypeScript knows: no extra properties
    case 'scanning': 
      return console.log(`Scanning... ${state.progress}%`); // ✅ progress exists
    case 'exploiting': 
      return console.log(`Exploiting ${state.target}...`); // ✅ target exists
    case 'complete': 
      return state.results; // ✅ results exists
  }
}
👶

Explain Like I'm 3

Imagine you're playing a video game with 4 levels: "sleeping", "looking for treasure", "digging for treasure", and "found it!". Each level has different things you can do. When you're sleeping, you just wait. When you're looking, you can see how far you've walked. When you're digging, you know what spot you're digging at. When you found it, you get the treasure! The game always knows which level you're on, so it shows you the right stuff.

💼

Explain Like You're My Boss

This demonstrates exhaustive pattern matching in our Red Team attack simulations. Each attack stage has distinct data requirements and UI states. The discriminated union ensures we handle all stages explicitly - if we add a new stage and forget to handle it, the compiler flags it immediately.

Business Impact: This pattern powers our 27 Red Team lab modules, ensuring attack progressions render correctly without runtime errors. Users see professional, bug-free simulations, directly impacting our platform's credibility and user retention.

💕

Explain Like You're My Girlfriend

You know how you have different moods and I need to respond differently to each one? Like if you're "hangry", I better have food ready. If you're "tired", I know not to suggest going out. If you're "excited", I better match that energy. If you're "fine" (with the emphasis), I know I messed up somehow and need to figure out what I did wrong. This code does the same thing - it checks which "mood" the attack simulator is in ('idle', 'scanning', 'exploiting', or 'complete') and responds appropriately to each one. No more getting it wrong and making things worse! 😅


#2. Branded Types (Nominal Types)

Problem: Prevent mixing up similar primitive types.

The Problem

// ❌ Both are just strings - TypeScript can't tell them apart
type UserId = string;
type Email = string;

function sendEmail(email: Email) { /* ... */ }
function createUserId(id: string): UserId { 
  return id as UserId; 
}

const userId = createUserId("user123");
const email = "user@example.com";

sendEmail(userId); // ❌ Compiles fine but is WRONG!
👶

Explain Like I'm 3

You have a red crayon and a blue crayon. Both are crayons, but they're different colors! If I ask you for the blue crayon and you give me the red one, that's wrong! But if both crayons are just called "crayon", how do I know which one you gave me? We need to call them "red crayon" and "blue crayon" so we don't mix them up!

💼

Explain Like You're My Boss

Without branded types, we have a critical type safety vulnerability: UserId and Email are both aliases for string, which means TypeScript allows them to be used interchangeably. This has led to production incidents where user IDs were passed to email functions, resulting in failed email deliveries and customer confusion. The compiler cannot help us catch these logical errors because, to TypeScript, they're all just strings.

Cost: Each misidentified parameter bug costs ~2 hours of developer time to trace and fix, plus potential customer impact.

💕

Explain Like You're My Girlfriend

Remember when I accidentally sent your birthday gift to my mom's address? Because in my phone, both were just saved as "Address"? Same thing here. A user ID is like "123abc" and an email is like "hello@email.com" - they're both just text to the computer. So when I try to send an email to "123abc", it breaks! It's like that time I ordered Thai food to your old apartment and you were like "babe I moved 6 months ago." This is why I have trust issues with strings.

The Solution

Create branded types using & { readonly __brand: 'UserId' }:

// ✅ Branded types - TypeScript can tell them apart
type UserId = string & { readonly __brand: 'UserId' };
type Email = string & { readonly __brand: 'Email' };

function sendEmail(email: Email) { /* ... */ }
function createUserId(id: string): UserId { 
  return id as UserId; 
}

const userId = createUserId("user123");
const email = "user@example.com" as Email;

sendEmail(userId); // ✅ TypeScript error: Type 'UserId' is not assignable to type 'Email'

Impact: TypeScript now catches you trying to use a UserId where an Email is expected.

👶

Explain Like I'm 3

Now we put special stickers on our crayons! The red crayon gets a "RED CRAYON" sticker, and the blue crayon gets a "BLUE CRAYON" sticker. If I ask for the blue crayon and you try to give me the red one, I can see the sticker and say "No, that's wrong! I need BLUE!" The stickers help us never mix them up again!

💼

Explain Like You're My Boss

Branded types add zero runtime overhead while providing compile-time protection against parameter mixups. The __brand property is a phantom type - it only exists at compile time for type checking, not at runtime. This pattern has eliminated ID/email confusion bugs across our authentication system, user management, and notification services.

Measurable Impact: Zero incidents of ID-type mixups in the last 6 months since implementing branded types throughout the codebase.

💕

Explain Like You're My Girlfriend

Okay so NOW both addresses in my phone have labels! One says "Your Current Address (the one you've lived at since March, yes I remember now)" and the other says "Old Apartment (DO NOT SEND ANYTHING HERE)". So when I order food, my phone literally won't let me pick the wrong address. It's like having you double-check everything I do, except it's TypeScript doing it, and it doesn't give me that look when I mess up. Also yes I know I should have updated your address sooner, I'm sorry! 😅💕


#3. Const Assertions for Better Inference

TypeScript's default inference is often too wide. Use as const to narrow it.

Before: Too Wide

// ❌ TypeScript infers: string[]
const ATTACK_TYPES = ['phishing', 'xss', 'sql-injection'];

function isValidAttack(type: string) {
  return ATTACK_TYPES.includes(type); // Type is just 'string'
}
👶

Explain Like I'm 3

You have a toy box with only 3 toys: a car, a ball, and a teddy bear. But when someone asks "what's in the box?", you just say "toys" instead of saying exactly which toys. That's not helpful! If I ask for a dinosaur, you should be able to say "No, I only have a car, ball, and teddy bear!" But you can't, because you just said "toys".

💼

Explain Like You're My Boss

Without as const, TypeScript infers ATTACK_TYPES as a mutable array of strings, not a specific tuple of exact string literals. This means we lose autocomplete, we can't create union types from the array values, and we can't leverage TypeScript's literal type narrowing. This impacts developer experience (slower coding), introduces potential bugs (accepting invalid attack types), and makes refactoring risky.

DX Impact: Developers waste time checking docs instead of relying on autocomplete.

💕

Explain Like You're My Girlfriend

You know how I always say "let's get food" and you're like "okay but WHAT food?" and I'm like "idk, food?" and you get frustrated because that's not helpful? That's what this code is doing. It's saying "these are attack types" but not being specific about WHICH attack types. So when another part of the code asks "is 'password-spray' a valid attack?", it can't answer properly because it only knows "it's a string" not "it's specifically phishing, xss, or sql-injection". Be specific, James! (This is me talking to myself like you talk to me when I'm being vague about dinner plans)

After: Precise

// ✅ TypeScript infers: readonly ['phishing', 'xss', 'sql-injection']
const ATTACK_TYPES = ['phishing', 'xss', 'sql-injection'] as const;

type AttackType = typeof ATTACK_TYPES[number]; // 'phishing' | 'xss' | 'sql-injection'

function isValidAttack(type: string): type is AttackType {
  return ATTACK_TYPES.includes(type as AttackType);
}

// Usage
const userInput = 'phishing';
if (isValidAttack(userInput)) {
  // ✅ TypeScript knows userInput is 'phishing' | 'xss' | 'sql-injection'
  console.log(userInput.toUpperCase());
}
👶

Explain Like I'm 3

NOW when someone asks "what's in the toy box?", you can say "I have exactly 3 toys: a CAR, a BALL, and a TEDDY BEAR - and that's it!" So when I ask for a dinosaur, you can say "No! I ONLY have a car, ball, or teddy bear. Pick one of those!" Much better!

💼

Explain Like You're My Boss

With as const, we've locked the array to its exact literal values and made it immutable. This enables: (1) Type-safe union type extraction via typeof ATTACK_TYPES[number], (2) Compile-time validation of attack types across all modules, (3) IDE autocomplete showing exact valid values, and (4) Refactor-safe code - renaming an attack type in the array automatically updates all call sites.

Productivity Gain: ~5 minutes saved per developer per day from eliminating guesswork and typos. That's 20+ hours per year per developer.

💕

Explain Like You're My Girlfriend

PERFECT! Now when I say "let's get food", it's like I'm saying "let's get EITHER sushi, pizza, or tacos - pick one!" You know the exact options! You're not wondering if I secretly want Indian food or if I'm open to that new place. The options are CLEAR. And if you suggest burgers, I can say "No babe, I said sushi, pizza, or tacos. Those are the only 3 options tonight." (And yes before you ask, I'm picking up, not cooking. I know my limits.) This is what as const does - it makes the options crystal clear so nobody has to guess! 💕🍕


Conclusion

These patterns have transformed how I write TypeScript. They're not just academic exercises – they prevent real bugs, improve code maintainability, and make refactoring safer.

Start with discriminated unions. They're the most impactful pattern and will immediately make your code more type-safe.

Happy coding! 🚀

END OF ARTICLE
J

About JG

Full-stack developer specializing in web performance, authentication systems, and developer experience. Passionate about sharing real-world debugging stories and optimization techniques.

Terms of ServiceLicense AgreementPrivacy Policy
Copyright © 2025 JMFG. All rights reserved.