Programming
From Good to Great: Leveling Up Your TypeScript Game
Move beyond basic types. A practical guide to using advanced TypeScript patterns like utility types and branded types to write safer, more maintainable, and bug-free code.

Thad Krugman
So you’ve embraced TypeScript. You’re no longer passing any
around, and you’ve typed your function parameters with string
, number
, and boolean
. That’s a fantastic start, but it’s just scratching the surface.
The true power of TypeScript isn’t just about preventing typos; it’s about its ability to precisely model the domain of your application. It’s about creating a type system so robust that it eliminates entire classes of bugs before you even save the file. Let’s explore some of the patterns I use to go from good TypeScript to great TypeScript.
The Non-Negotiable Foundation: strict
Mode
Before we dive into advanced patterns, let’s set the record straight. If you’re not using strict: true
in your tsconfig.json
, you’re not getting the full value of TypeScript. Enabling strict mode turns on a suite of checks that form the bedrock of a truly type-safe project.
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true
}
}
Think of strict
mode as the starting line, not a feature to be added later.
Stop Reinventing the Wheel: Master Utility Types
TypeScript comes with a powerful set of built-in utility types that help you create new types from existing ones without repetitive boilerplate. Mastering them is a huge productivity win.
Here are a few of the most essential ones and their real-world use cases:
Pick<Type, Keys>
: You have a largeUser
object, but yourUserAvatar
component only needs thename
andavatarUrl
.Pick
lets you create a smaller, more specific type for that component’s props.Partial<Type>
: Your “update profile” form allows users to change any field.Partial<User>
creates a type where every property is optional—perfect for API update payloads.Omit<Type, Keys>
: When creating a new user, theid
andcreatedAt
fields are generated by the database.Omit
lets you create aNewUser
type based on theUser
type, but without those fields.
// Assume an existing User type
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
// Pick only the properties you need
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
// Make all properties optional for updates
type UserUpdate = Partial<User>;
// Create a type for a new user, omitting DB-generated fields
type NewUser = Omit<User, 'id' | 'createdAt'>;
The Bug-Zapper: True Safety with Branded Types
The Problem: Consider these two function signatures: function getUser(id: string)
and function getProduct(id: string)
. What stops you from accidentally passing a productId
to the getUser
function? At the type level, nothing. Both are just strings. This is a common source of bugs.
The Solution: “Branded” (or “nominal”) types. It’s a clever trick to make two types with the same underlying primitive (like a string) fundamentally incompatible.
type UserId = string & { readonly brand: 'UserId' };
type ProductId = string & { readonly brand: 'ProductId' };
// You can create simple casting functions
const toUserId = (id: string) => id as UserId;
const toProductId = (id: string) => id as ProductId;
function getUser(id: UserId): User {
// Implementation...
}
const user = getUser(toUserId('user-123')); // This works
const product = getUser(toProductId('prod-456')); // This throws a TypeScript error!
We haven’t changed the runtime value—it’s still a string—but we’ve added a unique “brand” at the type level. Now, TypeScript will throw a compile-time error if you try to mix them up, saving you from a potentially tricky bug.
Writing Smart, Reusable Types
As your application grows, you’ll find yourself needing to create more flexible and dynamic types. This is where advanced patterns like Conditional and Template Literal Types shine.
Conditional Types: Create types that change based on an input, much like a ternary operator in JavaScript. This is perfect for modeling varied API responses.
type SuccessResponse<T> = { status: 'success'; data: T };
type ErrorResponse = { status: 'error'; message: string };
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
function handleResponse(response: ApiResponse<User>) {
if (response.status === 'success') {
console.log(response.data.name); // TS knows `data` exists
} else {
console.error(response.message); // TS knows `message` exists
}
}
Template Literal Types: Build new string literal types dynamically. This is fantastic for creating type-safe event handlers or style variants.
type MarginDirection = 'Top' | 'Right' | 'Bottom' | 'Left';
type MarginProperty = `margin${MarginDirection}`;
// The type MarginProperty is now:
// 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft'
Final Thoughts: From Linter to Architectural Tool
Moving beyond basic types transforms TypeScript from a simple linter into a powerful architectural tool. These patterns aren’t just academic exercises; they lead to fewer bugs, better long-term maintainability, and a more confident development process. By encoding your business logic directly into the type system, you create a codebase that is not only safer but also largely self-documenting.