Introduction

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>:

TypeScript
// Without generics -- we lose type informationfunctionwrapInArray(value: any): any[] {
 return [value];
}
const result = wrapInArray("hello");
// result is any[] -- TypeScript has no idea it's a string[]// With generics -- type information is preservedfunctionwrapInArray<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:

TypeScript
// Explicit type argumentconst explicit = wrapInArray<string>("hello");
// Multiple type parametersfunctionmakePair<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.

TypeScript
// Generic interfaceinterfaceStorage<T> {
 getItem(key: string): T | undefined;
 setItem(key: string, value: T): void;
 removeItem(key: string): void;
 getAllItems(): T[];
}
// Generic class implementing the interfaceclassMemoryStore<T> implementsStorage<T> {
 private data: Map<string, T> = newMap();
 getItem(key: string): T | undefined {
 returnthis.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-safeconst userStore = newMemoryStore<{ name: string; email: string }>();
userStore.setItem("user1", { name: "Marcus", email: "[email protected]" });
const user = userStore.getItem("user1");
// user is { name: string; email: string } | undefinedconst numberStore = newMemoryStore<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>. Angular's Observable<T>. 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:

TypeScript
// Constraint: T must have a 'length' propertyinterfaceHasLength {
 length: number;
}
functionlogWithLength<TextendsHasLength>(value: T): T {
 console.log(`Length: ${value.length}`);
 return value;
}
logWithLength("hello"); // OK -- strings have .lengthlogWithLength([1, 2, 3]); // OK -- arrays have .lengthlogWithLength({ length: 10 }); // OK -- object has .length// logWithLength(42); // Error! number doesn't have .length// Using keyof for property accessfunctiongetProperty<T, KextendskeyofT>(obj: T, key: K): T[K] {
 return obj[key];
}
const person = { name: "Marcus", age: 30, active: true };
const name = getProperty(person, "name"); // stringconst age = getProperty(person, "age"); // numberconst 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, and typeof gives 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 plus infer, 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.

TypeScript
interfaceUser {
 id: number;
 name: string;
 email: string;
 avatar?: string;
 role: "admin" | "user" | "moderator";
}
// Partial -- all properties become optionaltype UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; avatar?: string; role?: ... }functionupdateUser(id: number, changes: Partial<User>): User {
 const existingUser = getUserById(id);
 return { ...existingUser, ...changes };
}
// Pick -- select specific propertiestype UserPreview = Pick<User, "name" | "avatar">;
// { name: string; avatar?: string }// Omit -- exclude specific propertiestype CreateUserPayload = Omit<User, "id">;
// { name: string; email: string; avatar?: string; role: ... }// Record -- create a mapped object typetype 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 functionfunctioncreateResponse(data: User, status: number) {
 return { data, status, timestamp: Date.now() };
}
type ApiResponse = ReturnType<typeofcreateResponse>;
// { 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.

Dozens of separate interface definitions for variations of a single entity type. TransactionCreate, TransactionUpdate, TransactionSummary, TransactionAdmin. New field added, several files missed. Refactoring to Partial, Pick, and Omit off one base type cut the definitions by more than half and killed that whole class of bug. This has bitten me on every project that doesn't use derived types.

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.

TypeScript
// Basic conditional typetype IsString<T> = Textendsstring ? true : false;
type A = IsString<string>; // truetype B = IsString<number>; // falsetype C = IsString<"hello">; // true (literal extends string)// Practical: Extract non-nullable typestype NonNullable<T> = Textendsnull | undefined ? never : T;
type CleanString = NonNullable<string | null | undefined>;
// string// Infer keyword -- extract types from other typestype UnwrapPromise<T> = Textends Promise<inferU> ? U : T;
type D = UnwrapPromise<Promise<string>>; // stringtype E = UnwrapPromise<Promise<number>>; // numbertype F = UnwrapPromise<boolean>; // boolean (not a Promise, returns T)// Extract array element typetype ArrayElement<T> = Textends (inferE)[] ? E : never;
type G = ArrayElement<string[]>; // stringtype H = ArrayElement<number[]>; // numbertype I = ArrayElement<[string, number]>; // string | number

infer 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.

TypeScript
// Make all properties nullabletype Nullable<T> = {
 [KinkeyofT]: T[K] | null;
};
interfaceConfig {
 host: string;
 port: number;
 debug: boolean;
}
type NullableConfig = Nullable<Config>;
// { host: string | null; port: number | null; debug: boolean | null }// Make all properties readonlytype DeepReadonly<T> = {
 readonly [KinkeyofT]: T[K] extendsobject
 ? DeepReadonly<T[K]>
 : T[K];
};
// Template literal types with mapped typestype Getters<T> = {
 [KinkeyofTas`get${Capitalize<string & K>}`]: () => T[K];
};
type Setters<T> = {
 [KinkeyofTas`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 typetype Accessors<T> = Getters<T> & Setters<T>;
type ConfigAccessors = Accessors<Config>;
// All getters and setters combined

The 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 from production code. Not contrived examples.

Pattern 1: Type-Safe API Response Wrapper

TypeScript
// Generic API response typestype ApiResult<T> =
 | { status: "loading" }
 | { status: "success"; data: T; meta: { total: number; page: number } }
 | { status: "error"; error: { code: number; message: string } };
// Generic fetch functionasync functionfetchApi<T>(url: string): Promise<ApiResult<T>> {
 try {
 const response = awaitfetch(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 asT,
 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 siteconst usersResult = awaitfetchApi<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:

TypeScript
// Generic validator typetype Validator<T> = (value: T) => string | null;
type FormValidators<T> = {
 [KinkeyofT]?: Validator<T[K]>[];
};
type FormErrors<T> = Partial<Record<keyofT, string>>;
functionvalidateForm<Textends Record<string, unknown>>(
 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;
}
// UsageinterfaceLoginForm {
 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" }

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:

TypeScript
// Define your event mapinterfaceAppEvents {
 userLogin: { userId: string; timestamp: number };
 userLogout: { userId: string };
 pageView: { path: string; referrer: string | null };
 error: { message: string; stack?: string };
}
// Generic, type-safe event emitterclassTypedEmitter<Eventsextends Record<string, unknown>> {
 private listeners = new Map<string, Set<Function>>();
 on<EextendskeyofEvents>(
 event: E,
 callback: (payload: Events[E]) => void
 ): () => void {
 const key = event asstring;
 if (!this.listeners.has(key)) {
 this.listeners.set(key, newSet());
 }
 this.listeners.get(key)!.add(callback);
 // Return unsubscribe functionreturn () => this.listeners.get(key)?.delete(callback);
 }
 emit<EextendskeyofEvents>(event: E, payload: Events[E]): void {
 const callbacks = this.listeners.get(event asstring);
 callbacks?.forEach(cb => cb(payload));
 }
}
// Usage -- everything is type-checkedconst emitter = newTypedEmitter<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.

Ravi Krishnan

Ravi Krishnan

Backend Engineer & Systems Programmer

Ravi is a backend engineer and systems programmer with deep expertise in Rust, Go, and distributed systems. He writes about performance optimization and scalable architecture.