Mastering TypeScript Generics: From any Hell to Type Safety Heaven
1. Why I Started Learning Generics
I made a terrible mistake when I first adopted TypeScript in my project. I sprinkled any everywhere. Every time I saw a red squiggly line, I just slapped an any on it. Problem solved, right? Wrong.
function getData(data: any): any {
return data;
}
function processUser(user: any) {
console.log(user.nmae); // typo, but no error
return user;
}
The first week was bliss. No more compiler errors. I was shipping features fast. Then production happened. The app crashed. user.nmae was undefined. I had typed nmae instead of name, and TypeScript didn't catch it because I used any.
That's when I understood. any is an off switch for TypeScript's protection. If you use any, you might as well write JavaScript. You get all the overhead of TypeScript with none of the benefits.
But realistically, sometimes you need to accept "any type". API wrappers, caching utilities, reusable components. How do you handle those without any?
Generics. They let you write flexible code that works with multiple types while preserving type safety. It was hard at first, but now I can't imagine writing TypeScript without generics. They're that powerful.
2. My First Encounter: "What is T?"
I first saw generics in React's documentation.
function useState<T>(initialValue: T): [T, (value: T) => void] {
// ...
}
"What the hell is T?" That was my first reaction. The angle brackets (<>) looked alien. A single letter T seemed cryptic. It felt like reading a math equation.
But as I kept reading code, a pattern emerged. When you write useState<number>(0), T becomes number. When you write useState<string>(""), T becomes string.
It clicked. Generics are "passing types as parameters". Just like you pass values as arguments to functions, you pass types as arguments to generics. Generics are like "functions for types".
The Mold Metaphor
The metaphor that made it stick for me was a baking mold (like a fish-shaped pastry mold).
- The Mold (Generic):
Box<T>is the mold. - The Ingredient (T):
Tis what goes into the mold. It could be red bean paste or custard cream. - The Result:
Box<number>is a box containing a number.Box<string>is a box containing a string.
One mold, multiple pastries by changing the ingredient. That's the essence of generics.
// Generic interface
interface Box<T> {
item: T;
}
const numberBox: Box<number> = { item: 123 };
const stringBox: Box<string> = { item: "hello" };
const userBox: Box<User> = { item: { id: 1, name: "Ratia" } };
You reuse the same Box structure while changing only the type. If you used any, TypeScript wouldn't know what's inside item. But with generics, numberBox.item is correctly inferred as number.
3. Generic Functions: Starting Simple
The simplest form is a generic function. Let's write an identity function that returns whatever you pass in.
// Bad: using any (❌)
function identityBad(arg: any): any {
return arg;
}
const result1 = identityBad(123); // result1's type: any (type info lost)
console.log(result1.toUpperCase()); // No error! Crashes at runtime
// Good: using generics (✅)
function identity<T>(arg: T): T {
return arg;
}
const result2 = identity(123); // result2's type: number (type info preserved)
console.log(result2.toUpperCase()); // Compile error! number has no toUpperCase
The difference is clear. any throws away type information. Generics preserve it. Type inference happens automatically. When you write identity(123), TypeScript infers T as number.
Generic Interfaces: Reusable Type Definitions
You can use generics in interfaces too. This is especially useful for common structures like API responses.
// Generic interface
interface ApiResponse<T> {
code: number;
message: string;
data: T; // This part varies
timestamp: number;
}
// User info
interface User {
id: number;
name: string;
email: string;
}
// Product info
interface Product {
id: number;
title: string;
price: number;
}
// Type-safe API functions
async function fetchUser(): Promise<ApiResponse<User>> {
const response = await fetch('/api/user');
return response.json();
}
async function fetchProduct(id: number): Promise<ApiResponse<Product>> {
const response = await fetch(`/api/product/${id}`);
return response.json();
}
// Usage
const userResponse = await fetchUser();
console.log(userResponse.data.name); // ✅ Autocomplete works!
console.log(userResponse.data.price); // ❌ Error! User has no price
const productResponse = await fetchProduct(123);
console.log(productResponse.data.price); // ✅ Works
After introducing this pattern to my project, API response handling became so much cleaner. The response structure is consistent, so you don't have to retype code, message, data every time. Just swap out the data part with a generic.
Generic Classes: Type-Safe Collections
You can use generics in classes too. Let's build a simple Stack data structure.
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
}
// Number stack
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3
// String stack
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.peek()); // "world"
// Custom object stack
interface Task {
id: number;
title: string;
done: boolean;
}
const taskStack = new Stack<Task>();
taskStack.push({ id: 1, title: "Learn Generics", done: true });
taskStack.push({ id: 2, title: "Build Project", done: false });
Now Stack can hold any type, but type safety is maintained. If you write numberStack.push("string"), you get a compile error.
4. Generic Constraints: Restricting T with extends
Sometimes generics are too flexible. You run into "I can't accept just anything..." situations.
For example, let's say you want to write a function that logs the length of arrays or strings.
// This doesn't work
function logLength<T>(arg: T): void {
console.log(arg.length); // ❌ Error! No guarantee T has length
}
TypeScript doesn't know what T is, so it can't confirm T has a length property. This is where you need constraints.
Using extends to Constrain
// T must have a length property
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): void {
console.log(arg.length); // ✅ Now it works
}
logLength("hello"); // OK (string has length)
logLength([1, 2, 3]); // OK (array has length)
logLength({ length: 10, value: 3 }); // OK (has length property)
logLength(10); // ❌ Error! number has no length
T extends Lengthwise means "T must at least have the shape of Lengthwise". This restricts T's freedom while guaranteeing the properties you need.
Using Multiple Generic Parameters
You don't have to use just one generic parameter. You can use T, U, K, etc., simultaneously.
// Return two values as a tuple
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const result1 = pair(1, "hello"); // [number, string]
const result2 = pair(true, { name: "Ratia" }); // [boolean, { name: string }]
This pattern is common when building data structures like Map, where keys and values have different types.
5. keyof with Generics: Type-Safe Object Access
When you want to safely access object properties, combining keyof with generics is incredibly powerful.
The Problem: Getting Object Properties
// Bad: using any (❌)
function getPropertyBad(obj: any, key: string): any {
return obj[key]; // Typos go unnoticed
}
const user = { name: "Ratia", age: 30 };
getPropertyBad(user, "height"); // Returns undefined, no error
"height" isn't a key in the user object, but TypeScript doesn't catch this. You get undefined at runtime, which causes errors later when you try to use it.
Solution: Type-Safe Access with keyof
// Good: using generics + keyof (✅)
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ratia", age: 30 };
const name = getProperty(user, "name"); // ✅ name's type: string
const age = getProperty(user, "age"); // ✅ age's type: number
const height = getProperty(user, "height"); // ❌ Compile error!
keyof T is a union type of all keys in the T object. For user, keyof typeof user is "name" | "age". When you write K extends keyof T, it means K must be one of T's keys.
And the return type is precise too. T[K] means "the type of the value corresponding to key K in object T". user["name"] is string, and user["age"] is number. This is inferred automatically.
When I first saw this pattern, it blew my mind. It completely prevents typos. Now if I write getProperty(user, "nmae"), my IDE immediately underlines it in red.
Real-World Example: Type-Safe pick Function
Let's build a function that picks specific keys from an object.
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}
interface User {
id: number;
name: string;
email: string;
password: string;
}
const user: User = {
id: 1,
name: "Ratia",
email: "ratia@example.com",
password: "secret123"
};
// Pick only public info, excluding password
const publicUser = pick(user, ["id", "name", "email"]);
// publicUser's type: { id: number; name: string; email: string; }
console.log(publicUser.name); // ✅ Works
console.log(publicUser.password); // ❌ Error! password isn't in Pick
I use this pattern a lot when building products. When sending API responses to the frontend, you can use pick or omit functions to exclude sensitive info like passwords in a type-safe way.
6. Conditional Types and infer: Advanced Type Inference
Conditional types are like if-else at the type level. And the infer keyword is like saying "TypeScript, please figure out what type this is for me."
This was the hardest part for me at first. It felt like programming at the type level. But once I understood it, I realized how powerful it is.
Basic Syntax: T extends U ? X : Y
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes" (literals are subtypes of string)
"If T extends string, return 'yes', otherwise 'no'."
infer: Extracting the Type Inside a Promise
The most common example is extracting the inner type of a Promise.
// Extract T from Promise<T>
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<Promise<number>>; // number
type C = Unwrap<string>; // string (if not a Promise, return as-is)
// Real-world example
async function fetchUser(): Promise<User> {
// ...
}
type UserType = Unwrap<ReturnType<typeof fetchUser>>; // User
infer U means "figure out what type is here and call it U". If Promise<string> comes in, TypeScript infers U as string.
At first, this felt like magic. Now I use it regularly, especially when extracting return types from library functions.
Extracting Function Parameter Types
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number): void {
console.log(`Hello, ${name}. You are ${age} years old.`);
}
type GreetParams = Parameters<typeof greet>; // [string, number]
infer P extracts the function's parameter types as a tuple. This is actually a built-in TypeScript utility type, but understanding how to build it yourself helps you grasp the principle.
Extracting Array Element Types
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<User[]>; // User
Extracts the element type from an array type. I use this often too.
7. Building Custom Utility Types
Sometimes the built-in utility types aren't enough. You need to build your own.
DeepPartial<T>: Making Nested Objects Optional Too
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
cache: {
enabled: boolean;
ttl: number;
};
}
type PartialConfig = DeepPartial<Config>;
const config: PartialConfig = {
database: {
host: "localhost" // port, credentials optional
}
// cache also optional
};
Partial only makes the top level optional. If you want to make nested object properties optional too, you need DeepPartial.
Real-World Pattern: Generic React Hooks
React is where I really felt the power of generics, especially when building custom hooks.
interface QueryResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useQuery<T>(url: string): QueryResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
const json = await response.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}
// Usage
interface User {
id: number;
name: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, loading, error } = useQuery<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <div>{data.name}</div>; // ✅ data.name is correctly typed as string
}
Thanks to generics, when you write useQuery<User>, the returned data is typed as User | null. You get autocomplete and typo prevention.
8. Summary: What Generics Really Are
When I first saw generics, they were intimidating. The angle brackets looked foreign, and T was confusing. But now I can't write TypeScript without them.
Here's what generics really are:
- Passing types as parameters. Like functions take arguments, generics take type arguments.
- The safe alternative to
any. You keep flexibility while maintaining type safety. - The key to reusability. Apply the same logic to multiple types.
Patterns I use most in real work:
- API response wrappers:
ApiResponse<T> - Custom hooks:
useQuery<T>,useForm<T> - Utility functions:
pick,omit,getProperty - Generic components:
List<T>,Table<T>
Mastering generics unlocks TypeScript's true power. It's hard at first, but keep using them and they become natural. And once you're comfortable with them, you can never go back to any.
9. Glossary
- Generic: A technique to parameterize types, increasing reusability like function arguments do for values.
- Type Parameter: Variables used in generics. Conventionally
T,U,K, etc. - Type Inference: The compiler deducing types from context without explicit annotation.
- Constraints (
extends): Forcing a generic typeTto follow a specific shape. keyofOperator: Extracts all keys of an object type as a union type.- Mapped Types: Iterating over all properties of an object type to create a new type.
- Conditional Types: Using conditional logic (
T extends U ? X : Y) at the type level. inferKeyword: Used in conditional types to deduce and assign a type to a variable.- Utility Types: Pre-built generic type tools provided by TypeScript (
Partial,Pick,Omit, etc.). - Union Type:
A | B. A or B. - Intersection Type:
A & B. Has properties of both A and B.
10. FAQ
Q1: What's the difference between any and unknown?
Both can accept any value, but unknown is safer.
-
any: Completely disables type checking. You can call any method. Runtime error risk.let value: any = "hello"; value.toUpperCase(); // OK value.foo.bar.baz(); // OK (no compile error, crashes at runtime) -
unknown: Type is unknown. Requires type checking before use.let value: unknown = "hello"; value.toUpperCase(); // ❌ Error! Can't call methods on unknown if (typeof value === "string") { value.toUpperCase(); // ✅ OK (after type guard) }
Rule: If you're unsure, use unknown instead of any. It's safer.
Q2: What's the difference between generics and any?
-
any: Completely loses type information.function identity(arg: any): any { return arg; } const result = identity(123); // result's type: any -
Generics: Preserves type information.
function identity<T>(arg: T): T { return arg; } const result = identity(123); // result's type: number
Q3: When should you use generics?
Use generics in these situations:
- Same logic, different types: When a function or class needs to work with multiple types.
- Type safety needed: When you'd want to use
any, but don't want to lose type safety. - Reusable components/utils: When building libraries, hooks, or utility functions.
Q4: When do you use infer?
Mainly when building libraries or utility types. Rarely used in regular application code. Utility types like ReturnType and Parameters use infer internally.
Use cases:
- Extracting inner types from Promises
- Extracting function return types or parameters
- Pulling out specific parts from complex types
Q5: Interface vs Type Alias?
Almost the same, but some differences:
-
Declaration Merging:
- Interface: Multiple declarations with the same name merge together.
- Type: Can't redeclare with the same name. Causes error.
-
Expressiveness:
- Type: Can express more complex types (unions
|, intersections&, tuples, etc.). - Interface: Only for object shapes.
- Type: Can express more complex types (unions
-
Extension:
- Interface: Uses
extendskeyword. - Type: Uses intersection (
&).
- Interface: Uses
Rule: Use interface for object types, type for unions or complex types. Libraries prefer interface because they need declaration merging.