Learning TypeScript Generics Through Real Examples
Introduction: Why Generics?
Let’s start with a question I get asked all the time:
“I’ve been writing TypeScript for a year, but I still don’t really understand generics. Should I just copy-paste them from Stack Overflow?”
Short answer: No. Let me show you why generics exist and when you actually need them.
Part 1: The Problem Generics Solve
Imagine you’re building a simple data fetching function. Let’s start without generics:
function fetchUser(id: number) {
return fetch(`/api/users/${id}`).then(res => res.json());
}
function fetchPost(id: number) {
return fetch(`/api/posts/${id}`).then(res => res.json());
}
function fetchComment(id: number) {
return fetch(`/api/comments/${id}`).then(res => res.json());
}
Question: What’s wrong with this code?
Answer:
- Lots of repetition
- No type safety -
.json()returnsany - Have to write new function for every resource type
Let’s try consolidating:
function fetchData(url: string) {
return fetch(url).then(res => res.json());
}
// Usage
const user = await fetchData('/api/users/1'); // Type: any 😞
const post = await fetchData('/api/posts/1'); // Type: any 😞
Better? Not really. We lost all type information.
This is exactly the problem generics solve: We need a way to write flexible functions while maintaining type safety.
Part 2: Your First Generic Function
Here’s the same function with generics:
function fetchData<T>(url: string): Promise<T> {
return fetch(url).then(res => res.json());
}
// Now we can specify what we're fetching
interface User {
id: number;
name: string;
email: string;
}
const user = await fetchData<User>('/api/users/1');
// ✅ user is typed as User, not any!
Let’s break down the syntax:
function fetchData<T>(url: string): Promise<T>
// ^^^ ^^^
// | |
// Type parameter Return type uses T
Think of <T> as a type variable - similar to how you’d use a regular variable:
// Regular variable
function greet(name: string) {
return `Hello, ${name}`;
}
// Type variable
function fetchData<T>(url: string): Promise<T> {
return fetch(url).then(res => res.json());
}
Just like name is a placeholder for actual string values, T is a placeholder for actual types.
Part 3: When TypeScript Can Infer Generics
Often, you don’t need to specify the generic type explicitly:
function identity<T>(value: T): T {
return value;
}
// TypeScript can infer T from the argument
const result = identity("hello"); // T is inferred as string
const num = identity(42); // T is inferred as number
// You CAN be explicit if you want
const explicit = identity<string>("hello");
Rule of thumb: Let TypeScript infer when possible. Be explicit when necessary (we’ll see examples).
Part 4: Practical Example - React Hook
Let’s build a realistic custom hook that uses generics:
Without generics (bad):
function useApi(url: string) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error }; // data is any 😞
}
// Usage
const { data } = useApi('/api/users/1');
data.name; // No autocomplete, no type checking 😞
With generics (good):
function useApi<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then((data: T) => setData(data))
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// Usage with type safety
interface User {
id: number;
name: string;
email: string;
}
const { data, loading, error } = useApi<User>('/api/users/1');
if (data) {
data.name; // ✅ Autocomplete works!
data.email; // ✅ Type checking works!
data.foo; // ❌ Error: Property 'foo' does not exist
}
Notice: The generic <T> flows through the entire hook - from the state to the return type.
Part 5: Multiple Type Parameters
Sometimes you need more than one type parameter:
// Example: A key-value store
function createStore<K, V>() {
const store = new Map<K, V>();
return {
get(key: K): V | undefined {
return store.get(key);
},
set(key: K, value: V): void {
store.set(key, value);
},
has(key: K): boolean {
return store.has(key);
}
};
}
// Usage
const userStore = createStore<number, User>();
userStore.set(1, { id: 1, name: 'Alice', email: '[email protected]' });
const user = userStore.get(1); // Type: User | undefined
const invalid = userStore.get('1'); // ❌ Error: string is not assignable to number
Multiple type parameters let you express relationships between different types in your function.
Part 6: Generic Constraints
Sometimes you need to restrict what types can be used. Enter constraints:
// Problem: We want to ensure T has an 'id' property
function findById<T>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
// ^^^^^^^^
// ❌ Error: Property 'id' does not exist on type 'T'
}
Solution: Add a constraint
// Constraint: T must have 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); // ✅ Now it works!
}
// Usage
interface User extends HasId {
name: string;
}
interface Post extends HasId {
title: string;
}
const users: User[] = [{ id: 1, name: 'Alice' }];
const posts: Post[] = [{ id: 1, title: 'Hello' }];
findById(users, 1); // ✅ Works
findById(posts, 1); // ✅ Works
const numbers = [1, 2, 3];
findById(numbers, 1); // ❌ Error: number doesn't extend HasId
The syntax <T extends HasId> means: “T can be any type, as long as it has at least an ‘id’ property.”
Part 7: Real-World Example - Form Handler
Let’s put it all together with a practical form handling example:
// Generic form values type
type FormValues = Record<string, any>;
// Form state
interface FormState<T extends FormValues> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
}
// Form actions
interface FormActions<T extends FormValues> {
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
setFieldError: (field: keyof T, error: string) => void;
setFieldTouched: (field: keyof T, touched: boolean) => void;
resetForm: () => void;
}
// The hook
function useForm<T extends FormValues>(
initialValues: T
): [FormState<T>, FormActions<T>] {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const setFieldValue = <K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }));
};
const setFieldError = (field: keyof T, error: string) => {
setErrors(prev => ({ ...prev, [field]: error }));
};
const setFieldTouched = (field: keyof T, touched: boolean) => {
setTouched(prev => ({ ...prev, [field]: touched }));
};
const resetForm = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return [
{ values, errors, touched },
{ setFieldValue, setFieldError, setFieldTouched, resetForm }
];
}
// Usage - Full type safety!
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
function LoginComponent() {
const [form, actions] = useForm<LoginForm>({
email: '',
password: '',
rememberMe: false
});
// All of these are type-safe:
actions.setFieldValue('email', '[email protected]'); // ✅
actions.setFieldValue('password', '12345'); // ✅
actions.setFieldValue('rememberMe', true); // ✅
actions.setFieldValue('email', 123); // ❌ Error: number not assignable to string
actions.setFieldValue('wrongField', 'value'); // ❌ Error: 'wrongField' not in LoginForm
return (
<form>
<input
value={form.values.email}
onChange={e => actions.setFieldValue('email', e.target.value)}
/>
{/* Full autocomplete and type checking! */}
</form>
);
}
What’s happening here:
T extends FormValues- Ensures T is an object typekeyof T- Gets all keys of T (’email’ | ‘password’ | ‘rememberMe’)T[K]- Gets the type of a specific fieldPartial<...>- Makes all properties optional (for errors/touched)
Part 8: Common Patterns Cheat Sheet
Pattern 1: Array Operations
// Get first element with correct type
function first<T>(array: T[]): T | undefined {
return array[0];
}
const nums = [1, 2, 3];
const firstNum = first(nums); // Type: number | undefined
const strings = ['a', 'b', 'c'];
const firstStr = first(strings); // Type: string | undefined
Pattern 2: Promise Wrapper
async function retry<T>(
fn: () => Promise<T>,
maxAttempts: number
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxAttempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
}
}
throw lastError!;
}
// Usage
const user = await retry(() => fetchData<User>('/api/users/1'), 3);
// Type: User
Pattern 3: Builder Pattern
class QueryBuilder<T> {
private filters: Array<(item: T) => boolean> = [];
where(predicate: (item: T) => boolean): this {
this.filters.push(predicate);
return this;
}
execute(items: T[]): T[] {
return items.filter(item =>
this.filters.every(filter => filter(item))
);
}
}
// Usage
interface Product {
id: number;
name: string;
price: number;
category: string;
}
const products: Product[] = [/* ... */];
const result = new QueryBuilder<Product>()
.where(p => p.price > 100)
.where(p => p.category === 'electronics')
.execute(products);
// Type: Product[]
Part 9: When NOT to Use Generics
Don’t use generics if:
- The type is always the same
// ❌ Unnecessary generic
function addNumbers<T extends number>(a: T, b: T): T {
return (a + b) as T;
}
// ✅ Just use number
function addNumbers(a: number, b: number): number {
return a + b;
}
- You’re just avoiding ‘any’
// ❌ This doesn't add value
function log<T>(value: T): void {
console.log(value);
}
// ✅ This is fine
function log(value: unknown): void {
console.log(value);
}
- The relationship is too complex
If you need 5+ type parameters, reconsider your design.
Part 10: Debugging Generics
When generics don’t work as expected, use these techniques:
Technique 1: Hover in VS Code
Hover over the variable to see what TypeScript inferred:
const result = fetchData<User>('/api/users/1');
// ^^^^^^ Hover to see: const result: Promise<User>
Technique 2: Explicit Type Annotations
// If inference fails, be explicit
const data: User = await fetchData<User>('/api/users/1');
Technique 3: Use TypeScript Playground
Copy your generic code to TypeScript Playground and experiment.
Summary: The Mental Model
Think of generics as function parameters for types:
// Regular function
function greet(name: string) {
return `Hello, ${name}`;
}
greet("Alice"); // Pass value
// Generic function
function identity<T>(value: T): T {
return value;
}
identity<string>("Alice"); // Pass type AND value
Key takeaways:
- Generics let you write reusable code while maintaining type safety
- Use
<T>for single type parameter,<K, V>for multiple - Add constraints with
extendswhen needed - Let TypeScript infer types when possible
- Be explicit when inference fails or for clarity
Practice Exercise
Try converting this code to use generics:
// Before
function getUserById(id: number) {
return fetch(`/api/users/${id}`).then(res => res.json());
}
function getPostById(id: number) {
return fetch(`/api/posts/${id}`).then(res => res.json());
}
// Your turn: Create a single generic function
function getById<T>(resource: string, id: number): Promise<T> {
return fetch(`/api/${resource}/${id}`).then(res => res.json());
}
// Usage
interface User { id: number; name: string; }
interface Post { id: number; title: string; }
const user = await getById<User>('users', 1);
const post = await getById<Post>('posts', 1);
Next Steps:
- Practice with your own code
- Read TypeScript handbook on generics
- Look at popular libraries (React, Redux) to see real-world examples
Questions? Leave a comment below!