Skip to main content

What if your types could adapt to ANY data shape while staying perfectly safe? What if you could write a type once and use it with strings, numbers, users, products — anything — and TypeScript would automatically figure out the right shape?

That's what generic types do. And once you understand them, you'll stop writing the same type over and over and start writing types that write themselves.

In the last chapter, you learned generic functions. Now we go deeper — into generic interfaces, generic type aliases, and the patterns that make TypeScript's standard library work.

Generic Interfaces: The Shape of Reusability​

A generic interface is a blueprint for an object whose shape depends on a type parameter:

interface Repository<T> {
findById(id: number): T | undefined;
findAll(): T[];
create(item: Omit<T, "id">): T;
update(id: number, item: Partial<T>): T;
delete(id: number): void;
}

This single interface describes a repository for ANY type. Repository<User> is a user repository. Repository<Product> is a product repository. The methods are the same. The types adapt automatically:

class UserRepository implements Repository<User> {
findById(id: number): User | undefined {
return db.users.find(u => u.id === id);
}

findAll(): User[] {
return db.users;
}

create(item: Omit<User, "id">): User {
const user = { ...item, id: nextId() };
db.users.push(user);
return user;
}

update(id: number, item: Partial<User>): User {
const user = this.findById(id);
if (!user) throw new Error("Not found");
Object.assign(user, item);
return user;
}

delete(id: number): void {
db.users = db.users.filter(u => u.id !== id);
}
}

The Repository<User> interface ensures every method is correctly typed. findById returns User. create takes Omit<User, "id"> (a User without the id). update takes Partial<User> (a User with all properties optional). You can't accidentally return the wrong type or accept the wrong arguments.

Generic Type Aliases: The Swiss Army Knife​

Type aliases with generics are even more flexible than interfaces:

type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;

type StringOrNull = Nullable<string>; // string | null
type NumberOrUndefined = Optional<number>; // number | undefined

You can compose generic types to build complex types from simple ones:

type ApiResponse<T, E = Error> = {
data: T;
status: number;
error?: E;
timestamp: Date;
};

type PaginatedResponse<T> = {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
};

type UserListResponse = PaginatedResponse<User>;
type ProductSearchResponse = PaginatedResponse<Product>;

The Utility Types You'll Use Every Day​

TypeScript ships with built-in generic utility types. These are the ones you'll reach for constantly:

// Partial: Make all properties optional
type PartialUser = Partial<User>;
// { name?: string; age?: number; email?: string; }

// Required: Make all properties required
type RequiredUser = Required<PartialUser>;
// { name: string; age: number; email: string; }

// Readonly: Make all properties readonly
type ReadonlyUser = Readonly<User>;
// { readonly name: string; readonly age: number; readonly email: string; }

// Pick: Select specific properties
type UserNameAndEmail = Pick<User, "name" | "email">;
// { name: string; email: string; }

// Omit: Remove specific properties
type UserWithoutEmail = Omit<User, "email">;
// { name: string; age: number; }

// Record: Create a type with specific keys and values
type PageRoutes = Record<"home" | "about" | "contact", string>;
// { home: string; about: string; contact: string; }

// Exclude: Remove types from a union
type NonNullableString = Exclude<string | null | undefined, null | undefined>;
// string

// Extract: Keep only matching types
type OnlyStrings = Extract<string | number | boolean, string>;
// string

// NonNullable: Remove null and undefined
type RequiredString = NonNullable<string | null | undefined>;
// string

// ReturnType: Get the return type of a function
type FetchResult = ReturnType<typeof fetch>;
// Promise<Response>

// Parameters: Get the parameter types of a function
type FetchParams = Parameters<typeof fetch>;
// [input: RequestInfo | URL, init?: RequestInit]

// Awaited: Unwrap a Promise type
type UserPromise = Promise<User>;
type ResolvedUser = Awaited<UserPromise>;
// User

These utility types are not magic. They're defined using the same generic type features you're learning. Here's how Partial is implemented:

type Partial<T> = {
[K in keyof T]?: T[K];
};

We'll learn how to write types like this in Chapter 13 (Mapped Types). For now, just know that every utility type is built from the same building blocks you're learning.

Generic Type Inference in Practice​

TypeScript infers generic type parameters from usage. This is most powerful with functions that return generic types:

function createStore<T>(initial: T) {
let state = initial;

return {
get: (): T => state,
set: (newState: T): void => { state = newState; },
update: (fn: (state: T) => T): void => { state = fn(state); },
};
}

const numStore = createStore(0);
// T inferred as number
numStore.get(); // Type: number
numStore.set(42); // OK
numStore.set("42"); // Error

const userStore = createStore<User>({ name: "Alice", age: 30, email: "alice@example.com" });
// T explicitly set to User
userStore.get().name; // Type: string

The Identity Crisis: When Inference Goes Wrong​

Sometimes TypeScript infers a wider type than you want:

function createPair<T>(first: T, second: T): [T, T] {
return [first, second];
}

const pair = createPair("hello", 42);
// T inferred as string | number — probably not what you wanted

The fix is to constrain or specify:

// Option 1: Explicit type parameter
const pair1 = createPair<string | number>("hello", 42);

// Option 2: Two type parameters
function createPair2<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const pair2 = createPair2("hello", 42); // [string, number]

Generic Type Relationships​

Generic types can relate to each other through constraints:

// A function that works with any object that has an 'id' property
interface HasId {
id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}

// Works with User, Product, or any type with an 'id' property
const user = findById(users, 1);
const product = findById(products, 1);

This is the generic constraint pattern: define the minimum shape a type must have, then write generic code against that shape.

Higher-Order Generic Types​

Generic types can take other generic types as parameters:

type Unwrap<T> = T extends Promise<infer U> ? U : T;
type UnwrapArray<T> = T extends (infer U)[] ? U : T;

type DeepUnwrap<T> = T extends Promise<infer U>
? DeepUnwrap<U>
: T extends (infer U)[]
? DeepUnwrap<U>
: T;

type A = DeepUnwrap<Promise<number[]>>; // number
type B = DeepUnwrap<Promise<Promise<string>>>; // string

This uses conditional types with infer — the subject of the next chapter. But the pattern is what matters: generic types that transform other types, building up complex type operations from simple ones.

The Standard Generic Patterns​

Pattern 1: The Container​

type Container<T> = {
value: T;
map<U>(fn: (value: T) => U): Container<U>;
};

Pattern 2: The Transformer​

type Transformer<TInput, TOutput> = (input: TInput) => TOutput;

Pattern 3: The Reducer​

type Reducer<TState, TAction> = (state: TState, action: TAction) => TState;

Pattern 4: The Selector​

type Selector<TState, TResult> = (state: TState) => TResult;

Pattern 5: The Async Wrapper​

type AsyncResult<T> = Promise<{ data: T; error: null } | { data: null; error: Error }>;

Try This: Generic Types​

  1. Create a generic Pair<T, U> type with first: T and second: U.
  2. Create a generic Dictionary<T> type with [key: string]: T.
  3. Write a generic Result<T, E> type that's either { ok: true; value: T } or { ok: false; error: E }.
  4. Use Pick, Omit, Partial, and Required on a User type. Observe the resulting types.
  5. Write a generic createCache<T>() function that returns { get(key: string): T | undefined; set(key: string, value: T): void }.

Time needed: 20 minutes.

What to notice: How generic types compose. Partial<Pick<User, "name" | "email">> gives you a type with only name and email, both optional. Each utility type does one thing, and you combine them to get exactly the type you need.

The Revelation​

You now understand generic types — the ability to write types that adapt to any data shape. But there's a question lurking: how do TypeScript's utility types actually WORK? How does ReturnType extract the return type from a function? How does Awaited unwrap a Promise?

The answer is the infer keyword. It's TypeScript's mind-reader — a way to extract types FROM other types. And it's the subject of the next chapter.


In the next chapter: conditional types, the infer keyword, recursive conditional types, and how to build your own type utilities that rival the standard library.