Practical TypeScript patterns I use every day
The TypeScript utility types, discriminated unions, and branded types that make my code cleaner and safer in production.
Practical TypeScript patterns I use every day
TypeScript's type system is powerful, but the real value comes from using patterns that make your code safer and more maintainable. After years of writing TypeScript in production, these are the patterns I reach for every single day.
Utility Types That Actually Matter
TypeScript ships with utility types that save you from writing repetitive type definitions. I use these three constantly:
Pick and Omit -- I use these when working with API responses or form data. You often need a subset of an interface:
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
}
// For API responses, exclude sensitive fields
type PublicUser = Omit<User, 'password'>;
// For form validation, only the fields we care about
type UserForm = Pick<User, 'name' | 'email'>;Partial -- Essential for update operations where not every field is required:
async function updateUser(id: string, updates: Partial<User>) {
// TypeScript knows updates might not have all User properties
return await db.users.update(id, updates);
}ReturnType and Parameters -- These extract types from function signatures. Incredibly useful when working with third-party libraries:
const fetchUser = async (id: string) => {
const response = await api.get(`/users/${id}`);
return response.data;
};
// Extract the return type without duplicating definitions
type UserData = ReturnType<typeof fetchUser>;Discriminated Unions for Better State Management
Discriminated unions are my go-to pattern for representing different states in an application. Instead of having nullable fields and boolean flags, I use a single type that clearly represents each possible state.
type AsyncData<T> =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<AsyncData<User>>({ status: 'loading' });
// TypeScript narrows the type based on status
if (user.status === 'loading') return <Spinner />;
if (user.status === 'error') return <Error message={user.error} />;
// Here, TypeScript knows user.data exists and is type T
return <div>{user.data.name}</div>;
}This pattern eliminates entire classes of runtime errors. You cannot access user.data without first checking that the status is 'success'. TypeScript will not let you.
I use this pattern for form validation, API responses, feature flags -- anywhere you have multiple possible states.
Branded Types for Domain Safety
Branded types let you create distinct types from primitive values. This prevents you from accidentally passing the wrong kind of string or number to a function.
// Create branded types for different kinds of IDs
type UserId = string & { readonly brand: 'UserId' };
type PostId = string & { readonly brand: 'PostId' };
// Helper functions to create branded values
const createUserId = (id: string): UserId => id as UserId;
const createPostId = (id: string): PostId => id as PostId;
// Now your functions are type-safe
function getUser(userId: UserId) { /* ... */ }
function getPost(postId: PostId) { /* ... */ }
// This will cause a TypeScript error:
const userId = createUserId('user-123');
const postId = createPostId('post-456');
getUser(postId); // Error: PostId is not assignable to UserIdI use branded types for any value where mixing them up would cause bugs: database IDs, URLs, timestamps, currency amounts. The runtime cost is zero, but the safety benefit is enormous.
Putting It All Together
These patterns work well in combination. Here is a real-world example from a project I built recently:
type ArticleId = string & { readonly brand: 'ArticleId' };
interface Article {
id: ArticleId;
title: string;
content: string;
authorId: UserId;
publishedAt: Date | null;
}
type ArticleData = AsyncData<Article>;
type ArticleForm = Omit<Article, 'id' | 'publishedAt'>;
// Clean function signatures with strong typing
async function createArticle(form: ArticleForm): Promise<Article> {
// Implementation here
}
async function updateArticle(
id: ArticleId,
updates: Partial<ArticleForm>
): Promise<Article> {
// Implementation here
}The Bottom Line
These patterns are not just academic exercises. They catch real bugs at compile time, make refactoring safer, and make your code easier to understand for the next developer who reads it.
Start with utility types if you are new to TypeScript. Add discriminated unions when you find yourself with complex state logic. Use branded types for values where identity matters. Your future self will thank you.