Discriminated unions, template literals, branded types, conditional types, and practical patterns.
Welcome everyone! Today we're diving into Advanced TypeScript Patterns, focusing on type-level programming techniques that you can apply in real-world codebases. We'll explore how to leverage TypeScript's type system to catch bugs at compile time, create self-documenting APIs, and build more maintainable applications. These aren't just theoretical concepts — these are patterns you'll use daily once you understand them. We'll cover discriminated unions for state management, template literal types for parsing strings, branded types for preventing ID mix-ups, and much more. Let's get started.
We have four key concepts here that form the foundation of advanced TypeScript. First, discriminated unions let you model state machines with exhaustive pattern matching — the compiler catches missing cases so you never forget to handle a state. Second, template literal types allow you to parse and validate string formats at compile time, whether that's API routes, event names, or CSS units. Third, branded types prevent accidental mixing of structurally identical but semantically different values like user IDs and order IDs. And finally, conditional types let you build type-level logic that transforms, filters, and maps types based on constraints. These patterns work together to create robust, type-safe applications.
Looking at this code, we're modeling every possible state of an API request as a separate type variant. The status field is the discriminant — it's the common property that TypeScript uses to narrow the type in each branch. Notice how in the switch statement, when we're in the success case, we can safely access state dot data because TypeScript knows it exists. Similarly, in the error case, state dot error is available. The default case with the never type gives us exhaustiveness checking — if we add a new state variant and forget to handle it, TypeScript will throw a compile error. The table shows the key benefits: exhaustiveness checking, automatic narrowing, elimination of impossible states like having both data and error simultaneously, and safe refactoring when you add new states.
Template literal types are incredibly powerful for validating string patterns at compile time. Here we're defining an Endpoint type that combines HTTP methods with API routes. Notice how we can assign 'GET /users' to a variable of type Endpoint, but if we tried 'FETCH /data', TypeScript would reject it because FETCH isn't a valid HTTP method. The real magic happens with the ExtractParams type — it uses recursive conditional types to parse route parameters out of path strings. When we call the handle function with a route like '/users/:userId/posts/:postId', TypeScript automatically infers that the params object should have userId and postId properties, both as strings. Try to access params dot foo and you get a compile error. This is compile-time route validation with zero runtime overhead.
TypeScript uses structural typing, which means a string is a string regardless of what it represents. Branded types solve this by adding a phantom property that exists only at the type level — there's zero runtime cost. Looking at the code, we define a Brand generic type with a unique symbol. Then we create domain-specific types like UserId and OrderId, both based on string, but they're incompatible with each other. The smart constructor functions like toUserId validate at the boundary and cast to the branded type. Now when we try to call getUser with an OrderId, TypeScript catches the error at compile time. This prevents an entire class of bugs where you accidentally pass the wrong ID type to a function. It's particularly valuable in large codebases where you're juggling multiple types of identifiers.
Conditional types are the if-else statements of TypeScript's type system. The basic syntax is T extends U question mark A colon B. Looking at the examples, IsString checks whether a type is a string and returns a boolean literal type. UnwrapPromise is more practical — it recursively unwraps nested promises, so Promise of Promise of string becomes just string. The ExtractStrings pattern filters a union type, keeping only the string members. The ApiResponse type at the bottom shows a powerful pattern for library authors — it extracts the return type from a function, handling both sync and async cases. The table summarizes common patterns: you can extract array element types, unwrap promises, and extract function signatures using the infer keyword in different positions.
Mapped types are like the map function for types — they iterate over every property and transform it. Looking at MyPartial, we use the bracket K in keyof T bracket pattern to iterate over all keys, adding a question mark to make them optional. The FormState type shows a practical application — given a User model, we transform each property into an object with value, error, and touched fields. This is perfect for form libraries. The UserFormState type shows the result — every field becomes a structured form field object. The key remapping syntax with 'as' is particularly powerful. The Getters example transforms property names, adding 'get' prefix and capitalizing them. So a User with name, email, and age becomes getName, getEmail, and getAge methods. This is compile-time code generation.
Type guards bridge the gap between runtime checks and compile-time types. The isFish function uses the 'pet is Fish' syntax — this tells TypeScript that when this function returns true, the parameter is definitely a Fish. The assertDefined function is an assertion function that narrows or throws — if it doesn't throw, TypeScript knows the value can't be null or undefined afterward. The practical example at the bottom shows how powerful this is with array methods. We have an array of ApiResult which could be success or error, and by using the isSuccess guard in filter, TypeScript narrows the entire array to only success results. Then we can map over it and access the data property safely. Without the type guard, TypeScript wouldn't know that filter removed all the error cases.
The 'as const' assertion creates the narrowest possible type. Without it, the colors array is typed as string array. With 'as const', it becomes a readonly tuple with literal types: 'red', 'green', 'blue'. This is incredibly useful for configuration objects. Looking at the config example, config dot api isn't just string — it's the literal type 'https://api.example.com'. The satisfies operator, introduced in TypeScript 4.9, is a game-changer. It validates that a value matches a type without widening it. In the theme example, we validate that myTheme conforms to the Theme type, but myTheme dot primary dot bg is still the literal '#3b82f6', not just string. This means you get both type safety and autocomplete on the exact values. The table summarizes when to use each: 'as const' for config objects and enums, 'satisfies' when you need typed constants with autocomplete.
Module augmentation lets you extend types from third-party libraries without forking their source code. The 'declare module' syntax at the top extends Express's Request interface, adding custom user and requestId properties. Now in every route handler, req dot user and req dot requestId are fully typed with no casting needed. This is essential for middleware that adds properties to request objects. Declaration merging works differently — when you declare multiple interfaces with the same name, TypeScript merges them. The EventMap example shows this in action. We declare EventMap with a login event in one place, then add a logout event elsewhere. TypeScript combines them, so keyof EventMap gives us both 'login' and 'logout'. This is how libraries like Express and Socket.io allow you to extend their types in a type-safe way.
Beyond the basic utility types, let's look at how to compose them for real-world patterns. The table on the left covers the essential built-in utilities. Partial and Required toggle optionality. Pick and Omit select or remove specific keys. Record creates typed dictionaries. Extract and Exclude filter union types. ReturnType and Awaited work with functions and promises. On the right, we're composing these utilities to create domain-specific types. CreateInput strips ID and timestamp fields using Omit — perfect for POST request bodies. UpdateInput makes those fields optional with Partial and adds back a required ID. The result is that CreateUser only has name and email, while UpdateUser has an ID plus optional name and email. This pattern eliminates duplication and ensures your input types stay in sync with your models.
This is where everything comes together. We're building a type-safe builder pattern that tracks which fields have been set at the type level. The Builder type takes a second generic parameter, Set, which is a union of the keys that have been configured so far. The set method adds a key to that Set union using Set pipe K. The build method is the clever part — it only exists when all required fields are set. We use a conditional type to check if there are any keys left that haven't been set. If everything's set, build returns a function. If not, build is never. Looking at the usage, when we set all three fields — host, port, and debug — we can call build and get our Config object. But if we create an incomplete builder that only sets host, trying to call build gives a compile error because build doesn't exist. The compiler tells us exactly which fields are missing.
Let's recap the key patterns we've covered today. Discriminated unions are your go-to for modeling state machines — use a discriminant field and the compiler will enforce exhaustive handling of every case. Branded types prevent ID, currency, and unit mix-ups with zero runtime overhead — wrap your primitives and use smart constructors at the boundaries. Template literal types let you parse and validate strings at compile time, whether that's routes, event names, or any structured format. The satisfies operator validates types without widening them, so you keep autocomplete on literal values. And finally, compose utility types like Partial, Omit, and Pick to derive input and output types from your domain models. These patterns will make your TypeScript code more robust, maintainable, and self-documenting. Thank you!
Hands-on implementation guides with detailed code examples, step-by-step instructions, and expanded explanations for each topic.