Alright, let’s talk about giving your interfaces and type aliases a serious upgrade. You’ve seen how generics can supercharge functions and classes, making them flexible and type-safe. It would be downright rude if we didn’t extend that same power to the contracts we define with interfaces and the shortcuts we create with type aliases. This is where you stop writing one-off, brittle type definitions and start building a truly scalable type system.

The Generic Interface: A Contract with a Blank Check

Think of a generic interface as a template for a contract. You’re defining the shape of something, but you’re leaving a few key details as placeholders to be filled in later. This is insanely useful for describing common patterns, like what it means to be an API response.

Ever written this horror show?

interface UserApiResponse {
  data: User;
  status: number;
}

interface ProductApiResponse {
  data: Product;
  status: number;
}

// ...and so on for every single entity in your app. Yawn.

You’re a better developer than that. Let’s use a generic interface instead.

interface ApiResponse<T> {
  data: T;
  status: number;
  isError?: boolean;
}

// Now, we just plug in the type we need:
const userResponse: ApiResponse<User> = await getUser();
const productResponse: ApiResponse<Product[]> = await getProducts();

See what we did there? ApiResponse<T> is a blueprint. It says, “Whatever T ends up being, there will be a data property of that type, a status number, and maybe an isError flag.” We’ve defined one interface that can perfectly describe an infinite number of response types. The compiler will now know that userResponse.data is a User and productResponse.data is an array of Products. No more casting from any; you’ve just built a safer, cleaner, and far less repetitive codebase.

Generic Type Aliases: Not Just for Objects

Interfaces are great for object shapes, but type aliases can describe any type, including functions, unions, and tuples. Slapping a generic on them is just as powerful.

A classic use case is describing a function that pairs two values together. Without generics, it’s a mess. With generics, it’s elegant.

// A non-generic attempt... yikes.
type PairingFunction = (a: any, b: any) => [any, any];

const pairer: PairingFunction = (a, b) => [a, b];
const result = pairer("hello", 42); // result is of type [any, any]. Useless.

// The generic way: brilliance.
type PairingFunction<T, U> = (a: T, b: U) => [T, U];

const pairer: PairingFunction<string, number> = (a, b) => [a, b];
const result = pairer("hello", 42); // result is now perfectly typed as [string, number]

We can get even fancier. Ever wanted to describe a function that takes a value of a certain type and returns a value of that same type? That’s a perfect job for a single generic parameter.

type IdentityFunction<T> = (input: T) => T;

const numberIdentity: IdentityFunction<number> = (x) => x + 0;
const stringIdentity: IdentityFunction<string> = (x) => `${x}`;

Default Types: Because We’re Not Savages

Sometimes, you want a sensible default for your generic. Maybe 90% of the time you’re using ApiResponse<T>, it’s for a User. For the other 10%, you still want the flexibility. Default types to the rescue.

interface ApiResponse<T = User> {
  data: T;
  status: number;
}

// This is now an ApiResponse<User>
const standardResponse: ApiResponse = { data: { name: 'Alice' }, status: 200 };

// This is an ApiResponse<Product>
const specialResponse: ApiResponse<Product> = { data: { price: 29.99 }, status: 200 };

This is a fantastic way to reduce boilerplate for the common case without sacrificing the power to handle the edge cases. Use it wisely.

Constraints: Putting Up Guardrails

Here’s the part everyone forgets until the compiler yells at them: you can’t just do anything with T. Inside your interface or type alias, T is a black box. If you try to access a property like T.name, TypeScript will rightly complain because it has no guarantee that T will have a name.

This is where constraints (extends) come in. They let you tell the compiler, “Relax, any type T you plug in here will at least have these properties.”

interface HasId {
  id: string | number;
}

// We've constrained T. It must be a type that fulfills the HasId interface.
interface ApiResponse<T extends HasId> {
  data: T;
  status: number;
}

// This works because User has an `id: number`
interface User {
  id: number;
  name: string;
}
const userResponse: ApiResponse<User> = { data: { id: 1, name: 'Bob' }, status: 200 };

// This will FAIL at compile time because Product lacks an `id`.
interface Product {
  price: number;
}
const productResponse: ApiResponse<Product> = { data: { price: 29.99 }, status: 200 };
// Error: Property 'id' is missing in type 'Product' but required in type 'HasId'.

Constraints are your best friend for writing robust, self-documenting generic types. They prevent other developers (or future you) from misusing your beautifully designed contracts. Always ask yourself: “What is the absolute minimum requirement I need from T to make this work?” That’s what your constraint should be.