Back to Blog
TypeScript9 min read

TypeScript Advanced Patterns: Generics & Utility Types

Dive deep into TypeScript's powerful features like generics and utility types. Learn how to write more flexible, maintainable, and type-safe code with practical examples and real-world scenarios.

Jay Salot

Jay Salot

Senior Full Stack AI Engineer

May 25, 2026 · 9 min read

Share
Code on a laptop screen

TypeScript's type system is a superpower, but sometimes you need more than just basic types. That's where advanced patterns like generics and utility types come in. In a project last year, I was wrestling with a complex data transformation pipeline. I ended up using almost every advanced TypeScript feature I could find to make it type-safe and maintainable. Let's explore some of these techniques and how they can help you write better TypeScript code.

Understanding TypeScript Generics

Generics are like functions for types. They allow you to write code that can work with a variety of types without sacrificing type safety. Instead of writing separate functions for each type, you can write a single generic function that adapts to the type you provide.

Basic Generic Functions

Let's start with a simple example: a function that returns the first element of an array.

function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const numbers = [1, 2, 3];
const firstNumber = firstElement(numbers); // firstNumber is of type number | undefined

const strings = ["hello", "world"];
const firstString = firstElement(strings); // firstString is of type string | undefined

In this example, <T> declares a type variable T. This allows us to specify the type of the array elements. The function returns either a value of type T or undefined if the array is empty.

Generic Interfaces and Classes

Generics aren't just for functions. You can also use them with interfaces and classes.

interface Result<T, E> {
  data?: T;
  error?: E;
}

class APIResponse<T> {
  constructor(public result: Result<T, string>) {}
}

const success: Result<number, string> = { data: 42 };
const failure: Result<number, string> = { error: "Something went wrong" };

const apiResponse = new APIResponse(success);

Here, Result is a generic interface that can represent either a successful result with data of type T or an error of type E. The APIResponse class uses the Result interface to encapsulate the API response. This is super useful for handling errors gracefully, especially in asynchronous operations.

Generic Constraints

Sometimes, you need to restrict the types that can be used with a generic. This is where generic constraints come in.

interface Printable {
  print(): void;
}

function printAndReturn<T extends Printable>(item: T): T {
  item.print();
  return item;
}

class Document implements Printable {
  print() {
    console.log("Printing document...");
  }
}

const doc = new Document();
printAndReturn(doc); // Works because Document implements Printable

// printAndReturn(123); // Error: Argument of type 'number' is not assignable to parameter of type 'Printable'.

The extends Printable constraint ensures that the printAndReturn function can only be used with types that implement the Printable interface. This allows you to safely call the print() method on the item parameter.

TypeScript Utility Types

TypeScript provides a set of built-in utility types that can help you manipulate and transform types. These are incredibly useful for avoiding repetitive type definitions and creating more concise code.

Partial and Required

Partial<T> makes all properties of type T optional, while Required<T> makes them all required.

interface User {
  id: number;
  name: string;
  email?: string;
}

type PartialUser = Partial<User>; // id?: number; name?: string; email?: string;
type RequiredUser = Required<User>; // id: number; name: string; email: string;

const partialUser: PartialUser = { name: "John" };
const requiredUser: RequiredUser = { id: 1, name: "Jane", email: "jane@example.com" };

I've found Partial particularly useful when dealing with form updates where users might only modify a subset of fields. It prevents you from having to redefine the whole type just to make a few properties optional.

Readonly

Readonly<T> makes all properties of type T read-only.

interface Config {
  apiUrl: string;
  timeout: number;
}

type ReadonlyConfig = Readonly<Config>;

const config: ReadonlyConfig = { apiUrl: "https://api.example.com", timeout: 5000 };
// config.apiUrl = "https://newapi.example.com"; // Error: Cannot assign to 'apiUrl' because it is a read-only property.

This is great for ensuring that certain objects are immutable after creation. Think configuration objects or data that shouldn't be modified after initialization.

Pick and Omit

Pick<T, K> selects a set of properties K from type T, while Omit<T, K> removes a set of properties K from type T.

interface Product {
  id: number;
  name: string;
  description: string;
  price: number;
}

type ProductSummary = Pick<Product, "id" | "name" | "price">; // id: number; name: string; price: number;
type ProductDetails = Omit<Product, "description">; // id: number; name: string; price: number;

const summary: ProductSummary = { id: 1, name: "Awesome Product", price: 99.99 };
const details: ProductDetails = { id: 1, name: "Awesome Product", price: 99.99 };

These are super handy for creating different views of the same data. For example, you might use Pick to create a summary view of a product for a list page, and Omit to remove sensitive fields when sending data to a third-party API.

Record

Record<K, T> creates a type with keys of type K and values of type T.

type CountryCodes = "US" | "CA" | "GB";

type CountryCurrency = Record<CountryCodes, string>; // US: string; CA: string; GB: string;

const currencyMap: CountryCurrency = {
  US: "USD",
  CA: "CAD",
  GB: "GBP",
};

This is perfect for creating dictionaries or maps where you know the possible keys in advance. It helps enforce type safety across your data structures.

Exclude and Extract

Exclude<T, U> removes from T all types that are assignable to U. Extract<T, U> extracts from T all types that are assignable to U.

type AllowedTypes = string | number | boolean;
type NumericTypes = number | bigint;

type StringOrBoolean = Exclude<AllowedTypes, NumericTypes>; // string | boolean
type NumberOnly = Extract<AllowedTypes, NumericTypes>; // number

These are more niche, but can be useful for advanced type manipulations. I used Exclude once to filter out specific error types from a union of possible error responses.

Conditional Types

Conditional types allow you to define types based on conditions. They are similar to ternary operators in JavaScript.

type IsString<T> = T extends string ? true : false;

type StringCheck = IsString<string>; // true
type NumberCheck = IsString<number>; // false

In practice, you'll see conditional types used a lot in library definitions to handle different input types and return corresponding output types.

Infer Keyword

The infer keyword allows you to infer a type within a conditional type.

type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function add(a: number, b: number): number {
  return a + b;
}

type AddReturnType = GetReturnType<typeof add>; // number

This is incredibly powerful for extracting types from existing functions or types. In the example, we extract the return type of the add function.

Mapped Types

Mapped types allow you to transform properties of an existing type.

interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = {
  readonly [K in keyof Person]: Person[K];
};

const person: ReadonlyPerson = { name: "Alice", age: 30 };
// person.age = 31; // Error: Cannot assign to 'age' because it is a read-only property.

This allows you to create new types based on existing ones, applying transformations to each property. The keyof operator gives you a union of all property keys in a type.

Real-World Examples and Use Cases

Let's look at some practical examples of how these advanced patterns can be used in real-world scenarios.

API Client Generation

Imagine you're building an API client that needs to handle different response types based on the endpoint. Generics and conditional types can be used to create a type-safe API client.

interface APIResponse<T> {
  data: T;
  status: number;
}

async function fetchAPI<T>(url: string): Promise<APIResponse<T>> {
  const response = await fetch(url);
  const data = await response.json();
  return { data, status: response.status };
}

interface User {
  id: number;
  name: string;
}

async function getUser(id: number): Promise<APIResponse<User>> {
  return fetchAPI<User>(`/users/${id}`);
}

This allows you to define the expected response type for each API endpoint, ensuring type safety throughout your application.

Redux Reducer Types

When working with Redux, you can use utility types to define reducer types more concisely.

interface State {
  count: number;
  isLoading: boolean;
}

type Actions = {
  type: "INCREMENT";
} | {
  type: "DECREMENT";
} | {
  type: "SET_LOADING";
  payload: boolean;
};

type Reducer<S, A> = (state: S, action: A) => S;

const reducer: Reducer<State, Actions> = (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    case "DECREMENT":
      return { ...state, count: state.count - 1 };
    case "SET_LOADING":
      return { ...state, isLoading: action.payload };
    default:
      return state;
  }
};

This helps ensure that your reducers are type-safe and that you're handling all possible actions correctly.

Common Pitfalls and Best Practices

While these advanced patterns are powerful, there are some common pitfalls to avoid.

Overusing Generics

Don't use generics just for the sake of using them. If a type is known and fixed, there's no need to introduce a generic type variable. Overusing generics can make your code harder to read and understand.

Complex Conditional Types

Avoid creating overly complex conditional types. If a conditional type becomes too difficult to understand, consider breaking it down into smaller, more manageable types. Sometimes, simplicity trumps cleverness.

Ignoring Type Safety

Don't use any or unknown as a crutch. While they can be useful in certain situations, they defeat the purpose of using TypeScript in the first place. Try to be as specific as possible with your types to catch errors early.

Conclusion

TypeScript's advanced type system offers a wealth of tools for writing more robust and maintainable code. Generics allow you to write flexible code that can work with a variety of types. Utility types provide a set of built-in transformations that can help you avoid repetitive type definitions. Conditional types allow you to define types based on conditions. By mastering these patterns, you can take your TypeScript skills to the next level and build more sophisticated applications. The key takeaways? Use generics to avoid code duplication, leverage utility types to simplify type transformations, and always strive for type safety. It's an investment that pays off in the long run. Honestly, once you get the hang of these patterns, you'll wonder how you ever lived without them!

#TypeScript#generics#utility types#advanced patterns#type safety
Share

Related Articles