Generic Functions in TypeScript
Generic functions let you write a single function that works correctly across many types — without sacrificing type safety or resorting to any. They are one of TypeScript's most powerful features and the foundation of reusable, well-typed libraries.
Why Generics Exist
Imagine you want a function that returns the first element of an array. Without generics you face a choice: use any (and lose all type information) or write a separate function for every element type.
// ❌ Using any — type information is lost
function firstAny(arr: any[]): any {
return arr[0];
}
const n = firstAny([1, 2, 3]); // n is 'any' — no autocompletion, no safety
const s = firstAny(['a', 'b']); // s is also 'any'
// ❌ Overloads — works but does not scale
function firstNum(arr: number[]): number { return arr[0]; }
function firstStr(arr: string[]): string { return arr[0]; }
// ... one per type foreverYour First Generic Function
A generic function declares one or more type parameters inside angle brackets <T>. The type parameter acts as a placeholder that TypeScript fills in at every call site.
// ✅ Generic — one function, full type safety
function first<T>(arr: T[]): T {
return arr[0];
}
const n = first([1, 2, 3]); // TypeScript infers T = number
const s = first(['a', 'b', 'c']); // TypeScript infers T = string
const b = first([true, false]); // TypeScript infers T = boolean
// You can also supply the type argument explicitly
const x = first<number>([10, 20]);T is a convention, not a requirement. Use descriptive names like TItem, TKey, or TValue in production code to improve readability.Type Inference — Let TypeScript Do the Work
TypeScript can almost always infer the type argument from the arguments you pass. Explicit type arguments are mainly useful when inference fails or when you want to be explicit for documentation purposes.
function wrap<T>(value: T): { value: T } {
return { value };
}
// Inference in action
const a = wrap(42); // { value: number }
const b = wrap('hello'); // { value: string }
const c = wrap(null); // { value: null }
// Explicit — same result, but more verbose
const d = wrap<boolean>(true); // { value: boolean }Multiple Type Parameters
A function can have more than one type parameter. This is common when relating input and output types, or when building utilities like pair or zip.
// Two type parameters
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const p1 = pair(1, 'one'); // [number, string]
const p2 = pair(true, 42); // [boolean, number]
// Mapping one structure to another
function mapPair<A, B, C>(
p: [A, B],
fn: (a: A, b: B) => C
): C {
return fn(p[0], p[1]);
}
const result = mapPair([3, 4], (a, b) => a + b); // numberGeneric Arrow Functions
Arrow functions support generics too. In .tsx files you must add a trailing comma after the type parameter to avoid the parser treating <T> as a JSX opening tag.
// Regular .ts file — no issue const identity = <T>(value: T): T => value; // In a .tsx file — add a trailing comma const identityTsx = <T,>(value: T): T => value; // Multi-param arrow functions in TSX const swap = <A, B>(tuple: [A, B]): [B, A] => [tuple[1], tuple[0]]; const swapped = swap([1, 'hello']); // ['hello', 1] typed as [string, number]
.ts and .tsx files. A linter rule or project-wide convention saves cryptic JSX parse errors.Generic Functions with Optional Parameters
function getOrDefault<T>(value: T | undefined, fallback: T): T {
return value !== undefined ? value : fallback;
}
const name = getOrDefault(undefined, 'Anonymous'); // 'Anonymous' — string
const count = getOrDefault(5, 0); // 5 — number
// TypeScript enforces that fallback matches value's type
// getOrDefault<string>(undefined, 0) // ❌ Error: 0 is not assignable to stringReturning Different Types Based on Input
// Generic function returning a transformed type
function toArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value];
}
const a = toArray(1); // [1] — number[]
const b = toArray([1, 2]); // [1,2] — number[]
const c = toArray('hello'); // ['hello'] — string[]Real-World Example: A Typed Fetch Wrapper
One of the most common uses of generic functions in real applications is wrapping fetch so that the response is automatically typed.
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// Usage — the caller decides the shape
interface User {
id: number;
name: string;
email: string;
}
const user = await fetchJson<User>('/api/users/1');
// user.name — ✅ fully typed, autocompletion works
// user.foo — ❌ TypeScript error: Property 'foo' does not existas Promise<T> cast in fetchJson is a type assertion — TypeScript trusts you that the server returns the right shape. Always validate at runtime with a schema library (e.g. Zod) for production code.Generic Callbacks and Higher-Order Functions
Generics shine when writing higher-order functions — functions that accept or return other functions. The built-in Array.map, Array.filter, and Array.reduce are all generic in this way.
// Custom map — mirrors Array.prototype.map
function map<T, U>(arr: T[], fn: (item: T, index: number) => U): U[] {
return arr.map(fn);
}
const doubled = map([1, 2, 3], x => x * 2);
const names = map([{ name: 'Alice' }, { name: 'Bob' }], u => u.name);
// Custom filter — preserves element type
function filter<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
const evens = filter([1, 2, 3, 4], n => n % 2 === 0); // number[]
// Custom reduce
function reduce<T, U>(arr: T[], fn: (acc: U, item: T) => U, initial: U): U {
return arr.reduce(fn, initial);
}
const sum = reduce([1, 2, 3, 4], (acc, n) => acc + n, 0); // 10Composing Generic Functions
// A two-step pipeline utility
function pipe<A, B, C>(
fn1: (a: A) => B,
fn2: (b: B) => C
): (a: A) => C {
return (a) => fn2(fn1(a));
}
const double = (n: number) => n * 2;
const stringify = (n: number) => n.toString();
const doubleToString = pipe(double, stringify);
const result = doubleToString(5); // '10' — stringAsync Generic Functions
// Generic cache with async loader
async function memoize<T>(
key: string,
load: () => Promise<T>,
cache: Map<string, T>
): Promise<T> {
if (cache.has(key)) {
return cache.get(key) as T;
}
const value = await load();
cache.set(key, value);
return value;
}
const userCache = new Map<string, User>();
const alice = await memoize(
'user:1',
() => fetchJson<User>('/api/users/1'),
userCache
); // User — fully typedCommon Pitfalls
Mistake | Problem | Fix |
|---|---|---|
Using any instead of T | Defeats type checking entirely | Introduce a type parameter |
Widening inference with union literals | T infers as string | number | Use satisfies or explicit <T> |
Forgetting trailing comma in .tsx | Parser sees JSX tag | Write <T,> or use the function keyword |
Over-constraining T early | Function becomes less reusable | Add extends only when you need properties |
Generic Function Overloads
Generic functions can also be overloaded. This is useful when the return type should change based on which overload matches — something a single generic signature cannot always express.
// Overloaded generic function
function coerce(value: string): number;
function coerce(value: number): string;
function coerce<T extends string | number>(value: T): string | number {
if (typeof value === 'string') return Number(value);
return String(value);
}
const n = coerce('42'); // number
const s = coerce(42); // string
// Overloads for conditional return types
function parseInput(input: string): number;
function parseInput(input: number): string;
function parseInput(input: boolean): null;
function parseInput(input: string | number | boolean): number | string | null {
if (typeof input === 'string') return parseFloat(input);
if (typeof input === 'number') return input.toString();
return null;
}Inferring Types Inside Generic Functions
The infer keyword (used inside conditional types) can appear in generic function signatures through utility types, making it possible to extract parts of a type automatically.
// Extract the element type of any array
type ElementType<T> = T extends (infer E)[] ? E : never;
function head<T extends unknown[]>(arr: T): ElementType<T> {
return arr[0] as ElementType<T>;
}
const n = head([1, 2, 3]); // number
const s = head(['a', 'b']); // string
// Unwrap a Promise
type Unwrap<T> = T extends Promise<infer U> ? U : T;
async function fetchAndProcess<T>(
fetch: () => Promise<T>,
process: (data: T) => void
): Promise<void> {
const data = await fetch();
process(data);
}
// TypeScript infers T from the fetch return type
fetchAndProcess(
async () => ({ id: 1, name: 'Alice' }),
(user) => console.log(user.name) // user is { id: number; name: string }
);Generic Functions and Declaration Files
When writing .d.ts declaration files (for libraries or ambient modules), generic function signatures are declared the same way — just without the implementation body.
// mylib.d.ts declare function identity<T>(value: T): T; declare function map<T, U>(arr: T[], fn: (item: T) => U): U[]; declare function merge<A, B>(a: A, b: B): A & B; // These can then be consumed with full type safety const x = identity(42); // number const doubled = map([1,2,3], n => n * 2); // number[]
Generic Debounce and Throttle Utilities
Performance utilities like debounce and throttle are classic examples where generics shine — they need to preserve the exact signature of the wrapped function.
// Generic debounce — preserves the original function signature
function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
timer = null;
}, delay);
};
}
function search(query: string, page: number): void {
console.log(`Searching for "${query}", page ${page}`);
}
const debouncedSearch = debounce(search, 300);
debouncedSearch('typescript', 1); // waits 300ms
debouncedSearch('typescript generics', 1); // cancels previous, waits again
// Generic memoize — caches results by serialised arguments
function memoize<T extends (...args: unknown[]) => unknown>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = fn(...args) as ReturnType<T>;
cache.set(key, result);
return result;
}) as T;
}
function expensiveCalculation(n: number): number {
return n * n; // imagine this is expensive
}
const memoizedCalc = memoize(expensiveCalculation);
memoizedCalc(10); // computed: 100
memoizedCalc(10); // from cache: 100Quick Reference
Declare type params in <> before the parameter list: function fn<T>(arg: T): T
TypeScript infers T from call-site arguments — explicit annotation is optional
Use multiple params <A, B> when relating two independent types
Arrow functions in .tsx need a trailing comma: <T,>
Generics work with async functions: async function load<T>(): Promise<T>
Combine generics with overloads when the return type must change per input type
Use extends when you need to access properties on T (covered in Generic Constraints)