Introduction to TypeScript Generics in Reusable Components
You're writing a utility function that gets the first element from an array. You want it to work with numbers, strings, objects—anything. Without generics, you'd either lose type safety with any or write separate functions for each type. That's where TypeScript generics come in.
Generics let you write code that works with multiple types while keeping full type safety. Think of them as type parameters: placeholders that get filled in when you use the code. They're especially useful when building React components that need to handle different types of data.
Here's a simple Box<T> component that can display any content type:
interface BoxProps<T> {
children: T;
}
function Box<T>(props: BoxProps<T>) {
return <div>{props.children}</div>;
}
// TypeScript knows exactly what type each box contains
<Box>Hello World</Box> // Box<string>
<Box>{42}</Box> // Box<number>
<Box>{{name: "Alice"}}</Box> // Box<{name: string}>
The <T> syntax creates a type variable that TypeScript infers from what you pass in. No manual type annotations needed. TypeScript figures it out.
Implementing TypeScript Generics in Functions
TypeScript functions become more powerful with generics. Instead of writing different versions for different types, you write one function that adapts:
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
// TypeScript infers the return type based on the input
const firstNumber = getFirstElement([1, 2, 3]); // number | undefined
const firstName = getFirstElement(['a', 'b', 'c']); // string | undefined
const firstMixed = getFirstElement([true, 42, 'hi']); // boolean | number | string | undefined
You can use multiple type parameters when you need to track relationships between different types:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const userInfo = pair('user_123', {name: 'Alice', email: 'alice@example.com'});
// TypeScript knows: [string, {name: string, email: string}]
Generic functions really shine when working with arrays and data transformations:
function filterArray<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
// Use with API responses
interface Product {
id: string;
price: number;
inStock: boolean;
}
const products: Product[] = await fetchProducts();
const availableProducts = filterArray(products, p => p.inStock);
// Returns Product[], not just an array of unknowns
Default Type Parameters
Sometimes you want a generic to have a fallback type. TypeScript lets you specify default type parameters, making generics optional when you have a sensible default:
interface ApiResponse<T = unknown> {
data: T;
status: number;
timestamp: Date;
}
// Use with a specific type when you know it
const userResponse: ApiResponse<User> = await fetchUser();
// Use the default when you don't
const genericResponse: ApiResponse = await fetchSomething();
// data is typed as unknown
Default type parameters work well for configuration objects where you want flexibility but also safety:
class DataCache<T = string> {
private store = new Map<string, T>();
set(key: string, value: T): void {
this.store.set(key, value);
}
get(key: string): T | undefined {
return this.store.get(key);
}
}
// Default to string cache
const sessionCache = new DataCache();
sessionCache.set('token', 'abc123');
// Or specify a different type
interface CachedUser {
id: string;
lastSeen: Date;
}
const userCache = new DataCache<CachedUser>();
userCache.set('user_1', {id: 'user_1', lastSeen: new Date()});
Important: Required type parameters must come before optional ones with defaults. This won't work:
// ❌ Error: Required type parameters must not follow optional ones
interface Bad<T = string, U> {
first: T;
second: U;
}
// ✅ Correct: Required first, optional second
interface Good<U, T = string> {
first: T;
second: U;
}
Using TypeScript Generics in Interfaces and Classes
Interfaces with generics help define flexible, type-safe contracts. Here's a data fetcher that works with any API response:
interface DataFetcher<T> {
fetch(): Promise<T>;
}
class ApiDataFetcher<T> implements DataFetcher<T> {
constructor(private endpoint: string) {}
async fetch(): Promise<T> {
const response = await fetch(this.endpoint);
return response.json();
}
}
// Use it with different API shapes
interface TodoItem {
id: number;
title: string;
completed: boolean;
}
const todoFetcher = new ApiDataFetcher<TodoItem[]>('/api/todos');
const todos = await todoFetcher.fetch(); // TodoItem[]
You can also make classes generic to build reusable data structures:
class Result<T, E = Error> {
private constructor(
private value?: T,
private error?: E,
private isSuccess: boolean = true
) {}
static ok<T, E = Error>(value: T): Result<T, E> {
return new Result(value, undefined, true);
}
static err<T, E = Error>(error: E): Result<T, E> {
return new Result(undefined, error, false);
}
unwrap(): T {
if (!this.isSuccess) {
throw this.error;
}
return this.value!;
}
unwrapOr(defaultValue: T): T {
return this.isSuccess ? this.value! : defaultValue;
}
}
// Use it to handle operations that might fail
async function parseUserData(json: string): Promise<Result<User, ParseError>> {
try {
const data = JSON.parse(json);
return Result.ok(data);
} catch (e) {
return Result.err(new ParseError('Invalid JSON'));
}
}
Setting Constraints in TypeScript Generics
Type constraints restrict what types a generic can accept. This prevents errors and gives you access to specific properties or methods:
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], targetId: string): T | undefined {
return items.find(item => item.id === targetId);
// TypeScript knows item.id exists because of the constraint
}
interface User extends HasId {
name: string;
email: string;
}
interface Product extends HasId {
title: string;
price: number;
}
const users: User[] = [
{id: "1", name: "Alice", email: "alice@example.com"},
{id: "2", name: "Bob", email: "bob@example.com"}
];
const products: Product[] = [
{id: "p1", title: "Laptop", price: 999},
{id: "p2", title: "Mouse", price: 29}
];
// Same function works with both
const user = findById(users, "1"); // User | undefined
const product = findById(products, "p1"); // Product | undefined
You can constrain generics to primitive types, objects, or even specific values:
// Only accept objects (not primitives)
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return {...obj1, ...obj2};
}
const config = merge(
{apiUrl: 'https://api.example.com'},
{timeout: 5000}
);
// config: {apiUrl: string, timeout: number}
// Only accept types with a length property
function getLength<T extends {length: number}>(item: T): number {
return item.length;
}
getLength('hello'); // ✅ Works with strings
getLength([1, 2, 3]); // ✅ Works with arrays
getLength({length: 10}); // ✅ Works with custom objects
// getLength(42); // ❌ Error: number doesn't have length
The keyof operator works well with generics for type-safe property access:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
name: 'Alice',
age: 30,
email: 'alice@example.com'
};
const userName = getProperty(user, 'name'); // string
const userAge = getProperty(user, 'age'); // number
// const invalid = getProperty(user, 'missing'); // ❌ Error
Combining TypeScript Generics with React Components
Using generics in React components helps create reusable and type-safe UI components. For example, working with Convex's end-to-end type system, you can create components that are type-safe with your database schema:
import { Doc } from "../convex/_generated/dataModel";
interface DataListProps<T extends Doc<string>> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function DataList<T extends Doc<string>>(
{items, renderItem}: DataListProps<T>
) {
return (
<ul>
{items.map(item => (
<li key={item._id}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// Use it with type-safe database records
interface Task extends Doc<"tasks"> {
title: string;
completed: boolean;
assignee: string;
}
<DataList<Task>
items={tasks}
renderItem={task => (
<span>{task.title} - {task.assignee}</span>
)}
/>
You can also build generic form components that adapt to different data shapes:
interface FormProps<T> {
initialValues: T;
onSubmit: (values: T) => void;
children: (values: T, onChange: (updates: Partial<T>) => void) => React.ReactNode;
}
function Form<T>({initialValues, onSubmit, children}: FormProps<T>) {
const [values, setValues] = React.useState<T>(initialValues);
const handleChange = (updates: Partial<T>) => {
setValues(prev => ({...prev, ...updates}));
};
return (
<form onSubmit={(e) => {
e.preventDefault();
onSubmit(values);
}}>
{children(values, handleChange)}
</form>
);
}
// TypeScript ensures type safety across the entire form
interface LoginForm {
email: string;
password: string;
}
<Form<LoginForm>
initialValues={{email: '', password: ''}}
onSubmit={credentials => console.log(credentials)}
>
{(values, onChange) => (
<>
<input
value={values.email}
onChange={e => onChange({email: e.target.value})}
/>
<input
type="password"
value={values.password}
onChange={e => onChange({password: e.target.value})}
/>
</>
)}
</Form>
Practical Uses of TypeScript Generics
A generic cache implementation demonstrates how generics work with utility types and map types:
class Cache<T> {
private data = new Map<string, {value: T, timestamp: number}>();
private ttl: number; // Time to live in milliseconds
constructor(ttlMinutes: number = 60) {
this.ttl = ttlMinutes * 60 * 1000;
}
set(key: string, value: T): void {
this.data.set(key, {
value,
timestamp: Date.now()
});
}
get(key: string): T | undefined {
const entry = this.data.get(key);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > this.ttl) {
this.data.delete(key);
return undefined;
}
return entry.value;
}
clear(): void {
this.data.clear();
}
}
// Use with different types based on your needs
interface ApiUser {
id: string;
name: string;
permissions: string[];
}
const userCache = new Cache<ApiUser>(30); // 30-minute TTL
userCache.set('user_123', {
id: 'user_123',
name: 'Alice',
permissions: ['read', 'write']
});
const sessionCache = new Cache<string>(5); // 5-minute TTL
sessionCache.set('session_xyz', 'encrypted_token_data');
Another practical pattern is building type-safe API clients:
class ApiClient<TResponse> {
constructor(private baseUrl: string) {}
async get<T = TResponse>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
async post<TBody, T = TResponse>(
endpoint: string,
body: TBody
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
}
// Create clients for different APIs
interface GithubRepo {
name: string;
stars: number;
language: string;
}
const githubApi = new ApiClient<GithubRepo>('https://api.github.com');
const repo = await githubApi.get<GithubRepo>('/repos/microsoft/typescript');
Advanced Generic Patterns
After you've used basic generics for a while, TypeScript offers advanced patterns for more complex type manipulation.
Conditional Types
Conditional types let you choose a type based on a condition, similar to ternary operators for values:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// More practical: extract array element types
type ElementType<T> = T extends Array<infer E> ? E : T;
type StringArray = ElementType<string[]>; // string
type NumberArray = ElementType<number[]>; // number
type NotArray = ElementType<boolean>; // boolean
The infer Keyword
The infer keyword lets you extract types from other types. It's how TypeScript's built-in utility types like ReturnType work:
// Extract the return type of a function
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function fetchUser(): Promise<User> {
return fetch('/api/user').then(r => r.json());
}
type UserPromise = GetReturnType<typeof fetchUser>; // Promise<User>
// Extract array element types
type Unpack<T> = T extends (infer U)[] ? U : T;
type Numbers = Unpack<number[]>; // number
type Single = Unpack<string>; // string
// Extract parameter types
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number, email: string) {
return {name, age, email};
}
type CreateUserParams = Parameters<typeof createUser>;
// [string, number, string]
Conditional types and infer give you a lot of power, but don't overuse them. If you find yourself writing complex conditional types, consider whether a simpler approach might be clearer.
Common Pitfalls & When NOT to Use Generics
Generics are powerful, but they're easy to misuse. Here are the most common mistakes and when to skip generics altogether.
The Golden Rule: Type Parameters Should Relate Multiple Values
If a type parameter appears only once, it's not creating any relationship. You probably don't need it:
// ❌ Bad: T only appears once
function logValue<T>(value: T): void {
console.log(value);
}
// ✅ Better: Just use unknown or the specific type
function logValue(value: unknown): void {
console.log(value);
}
// ✅ Good: T relates input and output
function identity<T>(value: T): T {
return value;
}
Don't Overload Type Parameters
Too many type parameters make your code hard to use and understand:
// ❌ Too complex
function transform<T, U, V, W>(
input: T,
mapper1: (x: T) => U,
mapper2: (x: U) => V,
mapper3: (x: V) => W
): W {
return mapper3(mapper2(mapper1(input)));
}
// ✅ Better: Break it into smaller functions
function pipe<T, U>(value: T, fn: (x: T) => U): U {
return fn(value);
}
// Or use function composition instead
If you have more than three type parameters, step back and reconsider your design.
Generics Don't Exist at Runtime
A common mistake is expecting generics to affect runtime behavior:
// ❌ This won't work as expected
function createInstance<T>(): T {
return new T(); // Error: T is not a constructor
}
// ✅ Pass a constructor if you need runtime behavior
function createInstance<T>(constructor: new () => T): T {
return new constructor();
}
class User {
name = '';
}
const user = createInstance(User); // Works!
Using any Instead of Generics
If you reach for any because types are "too hard," you're throwing away type safety:
// ❌ Loses all type information
function getProperty(obj: any, key: string): any {
return obj[key];
}
const value = getProperty(user, 'name');
// value is any - no autocomplete, no type checking
// ✅ Use generics to preserve types
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const value = getProperty(user, 'name');
// value is string - full type safety
When You Don't Need Generics
Don't use generics if you're only working with one or two specific types:
// ❌ Overcomplicated for a simple case
function addNumbers<T extends number>(a: T, b: T): T {
return (a + b) as T;
}
// ✅ Just use the type directly
function addNumbers(a: number, b: number): number {
return a + b;
}
Skip generics when:
- You're only handling one specific type
- The logic is simple and doesn't benefit from type relationships
- Adding generics makes the code harder to read without clear benefits
- You'd need to immediately constrain the generic to one type anyway
Overly Constraining Generics
Constraints should be as loose as possible while still being safe:
// ❌ Too restrictive
function logLength<T extends string | Array<any>>(item: T): void {
console.log(item.length);
}
// ✅ More flexible - works with anything that has length
function logLength<T extends {length: number}>(item: T): void {
console.log(item.length);
}
// Now works with strings, arrays, typed arrays, custom objects...
logLength('hello');
logLength([1, 2, 3]);
logLength({length: 42, custom: true});
TypeScript generics work best when they solve a real problem: making code reusable while preserving type safety. If you're adding generics "just in case" or to make code look more sophisticated, you're probably overengineering it.
Final Thoughts on TypeScript Generics
TypeScript generics let you write flexible, type-safe code without sacrificing the benefits of static typing. They're the foundation for building reusable components, type-safe data structures, and APIs that adapt to different data shapes.
Know when to use them: reach for generics when you need to preserve type relationships across inputs and outputs, but keep implementations simple. A well-constrained generic function that's easy to use beats an overly complex one every time.
Remember: generics should make your code more reusable and safer, not more complicated. If you find yourself writing deeply nested conditional types or juggling five type parameters, step back and look for a simpler solution.
For more advanced type manipulation, utility types and Partial work hand-in-hand with generics to transform and adapt types. Need a deeper dive into generics with Convex operations? Check out Convex's type system guide for practical examples.