Skip to main content

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

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 large User object, but your UserAvatar component only needs the name and avatarUrl. 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, the id and createdAt fields are generated by the database. Omit lets you create a NewUser type based on the User 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.