TypeScriptGeneric Functions

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.

TS
// ❌ 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 forever
Your 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.

TS
// ✅ 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]);
Note
The name 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.

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

TS
// 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); // number
Generic 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.

TS
// 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]
Tip
The trailing-comma trick is a common source of confusion for developers who move between .ts and .tsx files. A linter rule or project-wide convention saves cryptic JSX parse errors.
Generic Functions with Optional Parameters

TS
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 string
Returning Different Types Based on Input

TS
// 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.

TS
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 exist
Warning
The as 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.

TS
// 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); // 10
Composing Generic Functions

TS
// 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' — string
Async Generic Functions

TS
// 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 typed
Common 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.

TS
// 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.

TS
// 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.

TS
// 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.

TS
// 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: 100
Quick 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)

Success
You now understand generic functions — the cornerstone of reusable TypeScript code. The next step is generic interfaces, which let you describe reusable data shapes with the same flexibility.