Full Stack TypeScript Mastery
Advanced Type Patterns
Building a Robust Type System
In modern full-stack development, the shape of your data is the source of truth. TypeScript's advanced patterns allow you to build a type system that is not only safe but also flexible and expressive. Instead of just annotating basic types, we can create a system that models complex application states and business logic, ensuring consistency from the database to the UI.
The goal is to write types that describe our data accurately, then let the TypeScript compiler check our work for us.
Generics The Reusable Blueprint
are TypeScript’s way of creating reusable components. They allow you to write a function or a class that can work with any data type, while still maintaining type safety. Think of them as variables for types.
Imagine a function that fetches data from an API. You don't know if it will return a user, a product, or a list of articles. A generic function can handle all these cases.
async function fetchData<T>(endpoint: string): Promise<T> {
const response = await fetch(`https://api.example.com/${endpoint}`);
return response.json();
}
// Usage
interface User {
id: number;
name: string;
}
const user = await fetchData<User>('users/1');
// `user` is now correctly typed as `User`
Sometimes, you need to ensure the generic type has certain properties. This is done with generic constraints using the extends keyword. For example, a function that gets the ID from an object needs to know that the object will always have an id property.
function getId<T extends { id: string | number }>(item: T): string | number {
return item.id;
}
// This works
getId({ id: 1, name: 'Product A' });
// This causes a compiler error
// getId({ name: 'Product B' });
You can also provide a default type for a generic, which is used if no other type is provided.
function createContainer<T = string>(initialValue: T): { value: T } {
return { value: initialValue };
}
const stringContainer = createContainer('hello'); // T is inferred as string
const numberContainer = createContainer(123); // T is inferred as number
const defaultContainer = createContainer(); // T is string by default, but this requires an initial value
Transforming Types on the Fly
Often, you need a type that is a slight variation of another. Instead of duplicating code, you can use utility types to transform existing types. Common built-in utilities include Partial<T>, Pick<T, K>, and Omit<T, K>.
| Utility | Description |
|---|---|
Partial<T> | Makes all properties of T optional. |
Pick<T, K> | Creates a new type by picking a set of properties K from T. |
Omit<T, K> | Creates a new type by removing a set of properties K from T. |
Let's see them in action with a UserProfile interface.
interface UserProfile {
id: number;
username: string;
email: string;
lastLogin: Date;
}
// For an update form, fields should be optional
type UserUpdatePayload = Partial<UserProfile>;
// { id?: number; username?: string; ... }
// For a public profile display, we only want username
type PublicProfile = Pick<UserProfile, 'username'>;
// { username: string; }
// For an internal log, we might not need the email
type UserLogEntry = Omit<UserProfile, 'email'>;
// { id: number; username: string; lastLogin: Date; }
These utilities are built using more fundamental patterns like and Conditional Types. Mapped types let you iterate over the keys of an existing type to create a new one. Conditional types let you create types that depend on a logical check, like an if/else statement for types.
Conditional Type:
T extends U ? X : Y(IfTis assignable toU, the type isX, otherwise it'sY).
Modeling Complex States
In any real application, you deal with changing states. A network request might be loading, successful, or failed. Modeling this with type safety can be tricky, but make it clean and robust.
A discriminated union is a pattern where you define a common property (the discriminant) in each interface of a union. TypeScript can then use this property to narrow down the exact type you're working with in a block of code.
// The discriminant here is the 'status' property
interface LoadingState {
status: 'loading';
}
interface SuccessState<T> {
status: 'success';
data: T;
}
interface ErrorState {
status: 'error';
error: Error;
}
type ApiCallState<T> = LoadingState | SuccessState<T> | ErrorState;
function handleState<T>(state: ApiCallState<T>) {
switch (state.status) {
case 'loading':
console.log('Fetching data...');
break;
case 'success':
// TypeScript knows `state` has a `data` property here!
console.log('Data fetched:', state.data);
break;
case 'error':
// TypeScript knows `state` has an `error` property here!
console.error('An error occurred:', state.error.message);
break;
}
}
Notice how inside each case block, TypeScript understands the specific shape of the state object. Accessing state.data in the 'error' case would result in a compile-time error, preventing bugs before they ever happen.
Time to test what you've learned about these advanced patterns.
What is the primary purpose of using generics in TypeScript?
You need to write a generic function logProperty that accepts an object and a key, ensuring the key actually exists on the object. Which code snippet correctly implements this with generic constraints?
By mastering generics, utility types, and discriminated unions, you can build complex, scalable applications with a high degree of confidence and maintainability.