When You Need a Generic
Type 'string' is not assignable to type 'T' — if you've hit this error, you're trying to write a generic function. Here's how to actually think about generics instead of fighting the type checker.
If you're writing the same function twice with different types, that's when you need a generic. Not before. Most generic tutorials start with syntax — angle brackets, T, K, V — and that's exactly backwards. The syntax is the easy part. The hard part is knowing when a function should be generic in the first place, and when you're overcomplicating something that should just take a string.
The decision comes first. Then the mechanics.
Your First Generic Function
The hello-world of generics. Moving on quickly, but look at the difference between any and <T>:
// Without generics -- we lose type information
function wrapInArray(value: any): any[] {
return [value];
}
const result = wrapInArray("hello");
// result is any[] -- TypeScript has no idea it's a string[]Now the generic version. It replaces the function above rather than sitting next to it, since two implementations with the same name in one file are a compile error.
// With generics -- type information is preserved
function wrapInArray<T>(value: T): T[] {
return [value];
}
const stringResult = wrapInArray("hello");
// stringResult is string[] -- TypeScript knows!
const numberResult = wrapInArray(42);
// numberResult is number[]
const objectResult = wrapInArray({ name: "Marcus", age: 30 });
// objectResult is { name: string; age: number }[]<T> declares a type parameter. TypeScript fills it in from what you pass. wrapInArray("hello") — T is string. wrapInArray(42) — T is number. Inference handles it. You almost never need to spell out the type argument.
But you can:
// Uses wrapInArray<T> from the previous block
// Explicit type argument
const explicit = wrapInArray<string>("hello");
// Multiple type parameters
function makePair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const pair = makePair("name", 42);
// pair is [string, number]
const anotherPair = makePair(true, [1, 2, 3]);
// anotherPair is [boolean, number[]]Flexibility of any, safety of explicit types. Convention is single uppercase letters — T, K, V, E. Use descriptive names like TPayload or TResponse in complex situations. Single letters are fine for simple functions. They're not fine when your generic has four type parameters and a reviewer is trying to figure out what U means.
Generic Interfaces and Classes
Functions are the obvious use case. Interfaces and classes are where generics become genuinely powerful — reusable data structures that enforce type safety at every call site.
// Generic interface (not named Storage, which would clash with the DOM's Web Storage type)
interface KeyValueStore<T> {
getItem(key: string): T | undefined;
setItem(key: string, value: T): void;
removeItem(key: string): void;
getAllItems(): T[];
}
// Generic class implementing the interface
class MemoryStore<T> implements KeyValueStore<T> {
private data: Map<string, T> = new Map();
getItem(key: string): T | undefined {
return this.data.get(key);
}
setItem(key: string, value: T): void {
this.data.set(key, value);
}
removeItem(key: string): void {
this.data.delete(key);
}
getAllItems(): T[] {
return Array.from(this.data.values());
}
}
// Usage -- fully type-safe
const userStore = new MemoryStore<{ name: string; email: string }>();
userStore.setItem("user1", { name: "Marcus", email: "marcus@example.com" });
const user = userStore.getItem("user1");
// user is { name: string; email: string } | undefined
const numberStore = new MemoryStore<number>();
numberStore.setItem("count", 42);
// numberStore.setItem("count", "hello"); // Error! Argument of type 'string' is not assignable
Storage logic defined once. userStore only accepts user objects. numberStore only accepts numbers. Wrong type? Caught at compile time.
This pattern is everywhere. React's useState<T>. RxJS's Observable<T> (which Angular uses heavily). Any ORM's repository pattern. You've been using generics this whole time — you just haven't been writing them.
Generic Constraints
Unconstrained generics are useless for anything beyond pass-through. If T can be anything, TypeScript won't let you access any properties — it can't guarantee they exist. The extends keyword narrows what T accepts, and this is where generics stop being academic and start being practical.
Here's what constrained generics look like — pay attention to getProperty, because that pattern shows up constantly in real code:
// Constraint: T must have a 'length' property
interface HasLength {
length: number;
}
function logWithLength<T extends HasLength>(value: T): T {
console.log(`Length: ${value.length}`);
return value;
}
logWithLength("hello"); // OK -- strings have .length
logWithLength([1, 2, 3]); // OK -- arrays have .length
logWithLength({ length: 10 }); // OK -- object has .length
// logWithLength(42); // Error! number doesn't have .length
// Using keyof for property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Marcus", age: 30, active: true };
const personName = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
const active = getProperty(person, "active"); // boolean
// getProperty(person, "email"); // Error! "email" is not a key of person
K extends keyof T ensures the key is valid. T[K] — indexed access type — returns the exact type of that specific property. getProperty(person, "name") returns string, not string | number | boolean. Precise. And precise return types are the whole point of generics.
If you reach for as any inside a generic function, your constraints aren't specific enough. Tighten them. The type errors usually resolve themselves.
The combination of
extends,keyof, andtypeofgives you a surprisingly expressive vocabulary for describing type relationships. Most generic functions only need one or two of these. If you're using all three plusinfer, step back and ask whether you're building something or proving something.
Built-in Utility Types
TypeScript ships with utility types built on generics. Learn these before writing custom ones — they cover more ground than people realize.
Partial<T> — all properties optional. Required<T> — the opposite. Pick<T, K> — select specific properties. Omit<T, K> — exclude specific properties. Record<K, V> — object type with keys K and values V. ReturnType<T> — extracts return type of a function.
interface User {
id: number;
name: string;
email: string;
avatar?: string;
role: "admin" | "user" | "moderator";
}
// Partial -- all properties become optional
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; avatar?: string; role?: ... }
// Stand-in for your data layer
declare function getUserById(id: number): User;
function updateUser(id: number, changes: Partial<User>): User {
const existingUser = getUserById(id);
return { ...existingUser, ...changes };
}
// Pick -- select specific properties
type UserPreview = Pick<User, "name" | "avatar">;
// { name: string; avatar?: string }
// Omit -- exclude specific properties
type CreateUserPayload = Omit<User, "id">;
// { name: string; email: string; avatar?: string; role: ... }
// Record -- create a mapped object type
type RolePermissions = Record<User["role"], string[]>;
// { admin: string[]; user: string[]; moderator: string[] }
const permissions: RolePermissions = {
admin: ["read", "write", "delete", "manage"],
user: ["read"],
moderator: ["read", "write", "delete"],
};
// ReturnType -- extract the return type of a function
function createResponse(data: User, status: number) {
return { data, status, timestamp: Date.now() };
}
type ApiResponse = ReturnType<typeof createResponse>;
// { data: User; status: number; timestamp: number }The payoff: instead of maintaining separate interfaces for CreateUser, UpdateUser, and UserPreview that drift out of sync whenever someone modifies the base User type, you derive them. Add a field to User and every derived type updates automatically. No files to hunt down. No forgotten updates.
A common failure mode: separate hand-written interfaces for variations of one entity, such as TransactionCreate, TransactionUpdate, TransactionSummary and TransactionAdmin. Someone adds a field to one and misses the others. Deriving them from one base type with Partial, Pick and Omit removes that whole class of bug, because there is only one definition to change.
Conditional Types
The type system starts to feel like a programming language here. T extends U ? X : Y. Ternary at the type level. Sounds academic, but you can't build a properly typed API client without conditional types. And you can't understand how NonNullable or Extract work without understanding the mechanics.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (literal extends string)
// Practical: Extract non-nullable types (re-creating the built-in NonNullable)
type MyNonNullable<T> = T extends null | undefined ? never : T;
type CleanString = MyNonNullable<string | null | undefined>;
// string
// Infer keyword -- extract types from other types
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type D = UnwrapPromise<Promise<string>>; // string
type E = UnwrapPromise<Promise<number>>; // number
type F = UnwrapPromise<boolean>; // boolean (not a Promise, returns T)
// Extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type G = ArrayElement<string[]>; // string
type H = ArrayElement<number[]>; // number
type I = ArrayElement<[string, number]>; // string | numberinfer is the key piece. Pattern matching for types. infer U says "if T is a Promise, figure out what it resolves to and call that U." You'll use this constantly once you know it exists.
Distributivity is the thing that will confuse you. Pass a union type to a conditional type and TypeScript applies the condition to each member individually, then combines results. That's why NonNullable<string | null | undefined> works — string doesn't extend null | undefined (keep), null does (discard), undefined does (discard). Result: string.
To prevent distributivity, wrap both sides in square brackets: [T] extends [U] ? X : Y. Forces evaluation on the full type at once. You won't need this often. But when you do, nothing else works.
Mapped Types and Template Literals
Looping over the properties of a type. { [K in keyof T]: SomeTransformation } iterates every key and produces a new type. Add or remove readonly and ? modifiers. Rename properties with template literals. This is where TypeScript's type system becomes genuinely programmable.
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface Config {
host: string;
port: number;
debug: boolean;
}
type NullableConfig = Nullable<Config>;
// { host: string | null; port: number | null; debug: boolean | null }
// Make all properties readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
// Template literal types with mapped types
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
type ConfigGetters = Getters<Config>;
// { getHost: () => string; getPort: () => number; getDebug: () => boolean }
type ConfigSetters = Setters<Config>;
// { setHost: (value: string) => void; setPort: (value: number) => void; ... }
// Combine them into a full accessor type
type Accessors<T> = Getters<T> & Setters<T>;
type ConfigAccessors = Accessors<Config>;
// All getters and setters combinedThe as clause remaps property names during mapping. host becomes getHost, port becomes getPort. Return types match the original property types automatically.
Filter properties by returning never for keys you want to exclude:
type StringPropsOnly<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] }
Overkill for small projects. Essential for library code and large codebases where types need to be generated programmatically. Framework authors rely on these patterns — it's how they provide the autocomplete that makes TypeScript worth using. If you're not writing a framework, you probably don't need this. But understanding it helps you read the framework code when things go wrong.
Real-World Generic Patterns
Three patterns that show up in real codebases, trimmed down to the parts that matter.
Pattern 1: Type-Safe API Response Wrapper
interface User {
id: number;
name: string;
}
// Generic API response types
type ApiResult<T> =
| { status: "loading" }
| { status: "success"; data: T; meta: { total: number; page: number } }
| { status: "error"; error: { code: number; message: string } };
// Generic fetch function
async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
try {
const response = await fetch(url);
if (!response.ok) {
return {
status: "error",
error: { code: response.status, message: response.statusText },
};
}
const json = await response.json();
return {
status: "success",
data: json.data as T,
meta: { total: json.total, page: json.page },
};
} catch (e) {
return {
status: "error",
error: { code: 500, message: "Network error" },
};
}
}
// Usage -- fully type-safe at every call site
async function listUsers() {
const usersResult = await fetchApi<User[]>("/api/users");
if (usersResult.status === "success") {
// TypeScript knows usersResult.data is User[] here
usersResult.data.forEach(user => console.log(user.name));
}
}Pattern 2: Generic Form Validation
Validation where field types actually matter. Try adding .includes() to a boolean field and TypeScript flags it immediately:
// Generic validator type
type Validator<T> = (value: T) => string | null;
type FormValidators<T> = {
[K in keyof T]?: Validator<T[K]>[];
};
type FormErrors<T> = Partial<Record<keyof T, string>>;
function validateForm<T extends object>(
data: T,
validators: FormValidators<T>
): FormErrors<T> {
const errors: FormErrors<T> = {};
for (const key in validators) {
const fieldValidators = validators[key];
if (!fieldValidators) continue;
for (const validate of fieldValidators) {
const error = validate(data[key]);
if (error) {
errors[key] = error;
break;
}
}
}
return errors;
}
// Usage
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
const errors = validateForm<LoginForm>(
{ email: "", password: "short", rememberMe: false },
{
email: [
(v) => !v ? "Email is required" : null,
(v) => !v.includes("@") ? "Invalid email" : null,
],
password: [
(v) => v.length < 8 ? "Must be at least 8 characters" : null,
],
}
);
// errors: { email: "Email is required", password: "Must be at least 8 characters" }
The constraint is T extends object, not Record<string, unknown>. An interface such as LoginForm has no implicit index signature, so under --strict it does not satisfy Record<string, unknown> and the call fails with TS2344. The same applies to the event map in the next pattern. Validators for email receive string. Validators for rememberMe receive boolean. Field-level type safety. Impossible without generics.
Pattern 3: Type-Safe Event Emitter
Both event name and payload type-checked. Socket.io uses this pattern:
// Define your event map
interface AppEvents {
userLogin: { userId: string; timestamp: number };
userLogout: { userId: string };
pageView: { path: string; referrer: string | null };
error: { message: string; stack?: string };
}
// Generic, type-safe event emitter
class TypedEmitter<Events extends object> {
private listeners = new Map<string, Set<Function>>();
on<E extends keyof Events>(
event: E,
callback: (payload: Events[E]) => void
): () => void {
const key = event as string;
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key)!.add(callback);
// Return unsubscribe function
return () => this.listeners.get(key)?.delete(callback);
}
emit<E extends keyof Events>(event: E, payload: Events[E]): void {
const callbacks = this.listeners.get(event as string);
callbacks?.forEach(cb => cb(payload));
}
}
// Usage -- everything is type-checked
const emitter = new TypedEmitter<AppEvents>();
emitter.on("userLogin", (payload) => {
// payload is { userId: string; timestamp: number }
console.log(`User ${payload.userId} logged in`);
});
emitter.emit("pageView", { path: "/home", referrer: null });
// emitter.emit("userLogin", { path: "/home" });
// Error! Missing 'userId' and 'timestamp', extra 'path'
Autocomplete for event names. Callback parameter types inferred automatically. Emit with wrong payload shape and the compiler stops you.
All three patterns share one thing: define the structure once, generics propagate it everywhere. Response type, form schema, event map. Not just reusable code — reusable code that carries its types along for the ride. And that distinction matters more than it sounds like it should.
Where to Go From Here
Generics are a tool, not a goal. If your type signature is longer than the function body, you've gone too far. Write the concrete version first, genericize when you need to.
Let TypeScript infer type parameters instead of specifying them. Use constraints instead of type assertions. Check the built-in utility types before writing custom ones. And if a function only ever operates on one type, just use that type. A generic should earn its place by being called with at least two different types. Otherwise it's ceremony.



