Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Generic Functions and Type Inference in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Introduction to Generics in TypeScript
TypeScript
9m
May 17, 2026

Introduction to Generics in TypeScript

Generics are the feature that separates TypeScript beginners from intermediate developers. They let you write a function or data structure once and have it work correctly across many types — with full type safety at every call site. This post starts from first principles.

#Generics#Type Parameters+5
Generic Constraints with extends in TypeScript
TypeScript
9m
Jun 14, 2026

Generic Constraints with extends in TypeScript

Generic constraints with extends let you limit what types a type parameter can accept while unlocking the specific properties and methods of those types. Instead of writing a generic function that can't do anything useful, you constrain T so TypeScript knows exactly what capabilities it has.

#Generic Constraints#TypeScript extends+5
Generic Classes in TypeScript
TypeScript
9m
Jun 7, 2026

Generic Classes in TypeScript

Generic classes let you write a single class that works correctly for any type you provide. TypeScript's built-in Map, Set, and Promise are all generic classes under the hood. In this post, you'll build your own — from a simple typed stack to a fully type-safe event emitter.

#Generic Classes#TypeScript Generics+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • How TypeScript Infers Generic Types
  • Explicit vs Implicit Type Arguments
  • Generic Arrow Functions
  • Inference from Multiple Parameters
  • When Inference Falls Short
  • Real-World Examples
  • Best Practices
  • Key Takeaways
TypeScript

Generic Functions and Type Inference in TypeScript

May 24, 2026•9 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript generic functions and type inference diagram
Generic Functions and Type Inference in TypeScript

How TypeScript Infers Generic Types

When you call a generic function, TypeScript looks at the arguments you pass and works backwards to figure out what each type parameter should be. This process is called type argument inference.

function wrap<T>(value: T): { value: T } {
  return { value };
}
 
wrap(42); // TypeScript infers T = number
wrap("hello"); // TypeScript infers T = string
wrap([1, 2, 3]); // TypeScript infers T = number[]

For each call, TypeScript matches the argument type against the parameter type T and resolves T to whatever the argument is. Simple cases like these infer instantly and correctly.

Inference gets more interesting when T appears in a nested position. TypeScript can still infer correctly by "unpacking" the structure:

function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
getFirst([1, 2, 3]); // T = number, return type: number | undefined
getFirst(["a", "b"]); // T = string, return type: string | undefined
getFirst([{ id: 1 }]); // T = { id: number }, return type: { id: number } | undefined

The parameter is T[]. TypeScript sees number[] passed in, matches T[] → number[], and resolves T = number. The inference follows the structure of the type.

Inference Happens at Each Call Site

Type inference is local to each function call. When you call getFirst([1, 2, 3]), TypeScript resolves T = number for that specific call. The next call to getFirst is resolved independently. Generic functions don't "remember" previous calls.

Explicit vs Implicit Type Arguments

Most of the time, let TypeScript infer. Explicit type arguments are there for when inference doesn't work or when you want to be more precise than inference allows.

When inference is sufficient — don't annotate:

// Inferred: T = number — no need to write <number>
const result = wrap(42);
 
// Inferred: T = string[] — clear and concise
const rev = reverse(["a", "b", "c"]);

When you need explicit type arguments:

// No arguments — TypeScript can't infer T
const arr = new Array<string>(5); // Explicit: T = string
 
// Inference gives too broad a type
const ids = [] as Array<number>; // Alternative: type assertion
const ids2: number[] = []; // Better: annotate the variable

When you want to constrain inference to a literal type:

function createTag<T extends string>(tag: T): `<${T}>` {
  return `<${tag}>`;
}
 
// Inferred: T = string (widened)
createTag("div"); // return type: `<${string}>`
 
// Explicit: T = "div" (literal)
createTag<"div">("div"); // return type: "<div>"

When you want TypeScript to check that an argument matches a specific shape:

function parseAs<T>(json: string): T {
  return JSON.parse(json) as T;
}
 
// You must be explicit — T has no argument to infer from
const user = parseAs<{ name: string }>('{"name":"Alice"}');
// user: { name: string }

Explicit Type Arguments as Documentation

Sometimes explicit type arguments serve as documentation rather than necessity. parseAs<User>(json) communicates intent clearly — even if TypeScript could infer T some other way, the explicit argument makes the code self-explanatory. Use this intentionally, not as a habit.

Generic Arrow Functions

Arrow functions in TypeScript can be generic too. The syntax places the type parameter before the parameter list:

const identity = <T>(value: T): T => value;
 
const getFirst = <T>(arr: T[]): T | undefined => arr[0];
 
const mapArray = <T, U>(arr: T[], fn: (item: T) => U): U[] => arr.map(fn);

This works perfectly in .ts files. In .tsx files (JSX), there's a problem: the TypeScript parser sees <T> and thinks it's an opening JSX tag, causing a parse error.

The fix is to add a trailing comma after T, which signals to the parser that this is a type parameter list, not a JSX tag:

// In .tsx files — add a trailing comma
const identity = <T>(value: T): T => value;
 
const getFirst = <T>(arr: T[]): T | undefined => arr[0];

The trailing comma is ignored by TypeScript's type system — it's purely a parser disambiguation hint. It's idiomatic in React/TypeScript codebases, so don't be surprised to see it.

Alternatively, add a constraint (even a trivially true one) to avoid the ambiguity:

// Also works in .tsx
const identity = <T extends unknown>(value: T): T => value;

JSX Ambiguity in .tsx Files

The trailing comma requirement only applies to arrow functions in .tsx files. Regular function declarations with function<T> syntax don't have this issue because function is unambiguously not a JSX tag. If you're writing utilities that will be used in TSX contexts, prefer regular function declarations or remember to add the comma.

Inference from Multiple Parameters

When a generic function has multiple parameters that reference the same type variable, TypeScript infers from all of them together:

function merge<T>(a: T, b: T): T {
  return { ...(a as object), ...(b as object) } as T;
}
 
merge({ x: 1 }, { y: 2 });
// TypeScript infers T = { x: number } | { y: number }? No —
// TypeScript infers T = { x: number } & { y: number }? Also no —
// Actually: T is inferred from the first argument, then the second must match

When two arguments both contribute to T, TypeScript tries to find a common supertype. If the types are compatible, inference succeeds. If not, TypeScript reports an error:

function pair<T>(a: T, b: T): [T, T] {
  return [a, b];
}
 
pair(1, 2); // OK — T = number
pair("a", "b"); // OK — T = string
pair(1, "b"); // Error — number and string have no common type

For callbacks, TypeScript infers the type parameter from the outer argument, then uses that resolved type to check the callback's parameter type:

function applyToAll<T>(arr: T[], fn: (item: T) => void): void {
  arr.forEach(fn);
}
 
applyToAll([1, 2, 3], (n) => console.log(n.toFixed(2)));
// T = number (inferred from arr)
// n is inferred as number — toFixed is valid

The array argument is processed first, resolving T = number. Then fn's parameter type is checked: (n: number) => void. TypeScript infers n as number inside the callback, giving you autocomplete and type checking there too.

When Inference Falls Short

Inference doesn't always produce the type you want. Two common situations:

1. No arguments to infer from:

function createPair<T>(): [T, T] {
  // T can't be inferred — no arguments
  return undefined as any;
}
 
createPair(); // T = unknown — not useful
createPair<number>(); // Explicit: T = number

2. Inference is too wide:

function makeArray<T>(...items: T[]): T[] {
  return items;
}
 
const arr = makeArray(1, 2, 3); // T = number — correct
const mixed = makeArray(1, "a"); // T = number | string — may or may not be what you want

When TypeScript infers a union type, it's being honest about what it saw. If you want a more specific type, provide it explicitly.

3. Return-type-only generics:

function fetchData<T>(url: string): Promise<T> {
  return fetch(url).then((r) => r.json());
}
 
// TypeScript can't infer T — it's only in the return type, not the parameters
const data = await fetchData("/api/user"); // T = unknown
const user = await fetchData<{ name: string }>("/api/user"); // T = { name: string }

When a type parameter only appears in the return type, TypeScript has nothing to infer it from. Explicit type arguments are the only option.

Return-type-only generics are common in deserialization functions, API clients, and dependency injection containers. The caller always knows what type they expect — they just need a way to communicate it to TypeScript. Explicit type arguments are the right tool for this.

Real-World Examples

A type-safe groupBy function:

function groupBy<T, K extends string | number | symbol>(
  arr: T[],
  getKey: (item: T) => K
): Record<K, T[]> {
  return arr.reduce(
    (acc, item) => {
      const key = getKey(item);
      if (!acc[key]) acc[key] = [];
      acc[key].push(item);
      return acc;
    },
    {} as Record<K, T[]>
  );
}
 
const users = [
  { name: "Alice", role: "admin" },
  { name: "Bob", role: "user" },
  { name: "Carol", role: "admin" },
];
 
const byRole = groupBy(users, (u) => u.role);
// byRole: Record<string, { name: string; role: string }[]>
// byRole.admin → [Alice, Carol]
// byRole.user  → [Bob]

T is inferred from the array elements, and K is inferred from the return type of the key function.

A pipeline / compose utility:

function pipe<A, B>(value: A, fn1: (a: A) => B): B;
function pipe<A, B, C>(value: A, fn1: (a: A) => B, fn2: (b: B) => C): C;
function pipe<A, B, C, D>(
  value: A,
  fn1: (a: A) => B,
  fn2: (b: B) => C,
  fn3: (c: C) => D
): D;
function pipe(value: unknown, ...fns: Array<(v: unknown) => unknown>): unknown {
  return fns.reduce((acc, fn) => fn(acc), value);
}
 
const result = pipe(
  "  hello world  ",
  (s) => s.trim(),
  (s) => s.split(" "),
  (arr) => arr.map((w) => w[0].toUpperCase() + w.slice(1)),
  (arr) => arr.join(" ")
);
// result: string — "Hello World"

Function overloads let the generic signatures encode the step-by-step type transformations through the pipeline.

A type-safe pick utility:

function pick<T extends object, 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;
}
 
const user = {
  id: 1,
  name: "Alice",
  email: "[email protected]",
  role: "admin",
};
const summary = pick(user, ["id", "name"]);
// summary: { id: number; name: string }
// summary.email — Error: Property 'email' does not exist

T is inferred from obj and K from keys. TypeScript guarantees every key in keys exists on T, and the return type is exactly the subset you picked.

Best Practices

Prefer function declarations for complex generics. Arrow function generics are syntactically noisier, especially with constraints. Function declarations are cleaner and avoid the TSX comma issue:

// Arrow — harder to read with constraints
const pick = <T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> => { ... }
 
// Declaration — cleaner
function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { ... }

Write the return type annotation on complex generics. TypeScript can usually infer return types, but for complex generics, an explicit return type annotation catches bugs early and serves as documentation:

// Explicit return type — compiler checks your implementation
function zip<A, B>(as: A[], bs: B[]): Array<[A, B]> {
  return as.map((a, i) => [a, bs[i]]);
}

Don't over-abstract with generics. If a function is only ever called with string values, make it take string. Generic code is more powerful but harder to read. Reach for generics when you genuinely need the flexibility.

Key Takeaways

  • TypeScript infers generic type parameters from function arguments — no explicit annotation needed in most cases
  • Explicit type arguments are for when inference can't resolve T (return-type-only generics) or when inference gives a type that's too wide
  • Arrow function generics in .tsx files require a trailing comma <T,> to avoid JSX parsing ambiguity
  • When multiple parameters share a type parameter, TypeScript finds a common supertype — mismatched types cause compile errors
  • Return-type-only generics always require explicit type arguments at the call site

Ready for Generic Interfaces and Types

You understand how generic functions work and how TypeScript resolves type parameters. Next up: generic interfaces and type aliases — how to build reusable data structures, describe complex APIs, and compose generic types together.