Generics 101: Reusable Functions and Types Without Sacrificing Type Safety
Starting from the pain of copy-pasting a function for every type it needs to support, this walks through how generics let functions and types stay both reusable and type-safe — no trade-off required.
A tradeoff that looks impossible to avoid
Say you want to write a simple function that just hands back whatever you pass it. A first pass might look like this:
function identity(value: number): number {
return value
}Then you need it to work with strings too, so you write another version:
function identityString(value: string): string {
return value
}Add booleans, objects, arrays, and this clearly doesn't scale — every new type means copy-pasting the function again. The tempting shortcut here is to just type everything as any:
function identity(value: any): any {
return value
}Now there's only one function, but you've paid for it with type safety. Pass in a string, and TypeScript has no idea the return value is also a string — value on the calling end is typed any, so the editor stops checking or suggesting anything about it:
const result = identity('hello')
result.toUpperCase() // fine
result.toFixed(2) // actually wrong, but TypeScript won't catch itThat's the tradeoff: you want one function that works with any type, but you don't want to give up type safety to get it. Those two goals sound mutually exclusive — and generics exist specifically to make them not be.
What generics actually are: think of them as type parameters
A regular function parameter lets you decide a value at call time. A generic lets you decide a type at call time. Think of it as an extra slot in the function signature — except instead of holding a value, it holds a type itself, and TypeScript fills that slot in automatically based on whatever you pass in.
Rewriting identity with a generic looks like this:
function identity<T>(value: T): T {
return value
}
const num = identity(42) // T is inferred as number, so num is number
const str = identity('hello') // T is inferred as string, so str is string<T> is that type slot, and T is the name of the type parameter. By convention it's a single uppercase letter (T, U, K, V), but there's nothing special about the name — <Value> would work just as well, the community just prefers short letters. The two Ts in the signature are tied together: whatever type goes in comes back out as the same type. That binding is exactly what makes generics stronger than any — any records no relationship at all, while a generic pins down that the input and output types must match.
Quick guess: if you call identity(42) and then try to call .toUpperCase() on the result, what happens? TypeScript throws an error, because T was already inferred as number — num is typed number, not left unchecked like it would be with any.
How does TypeScript figure out the type?
In the example above, we never told TypeScript what T was — it inferred it purely from the argument we passed in. That's called type inference, and most of the time you don't need to do anything manually:
identity(42) // T inferred as number
identity('hi') // T inferred as string
identity([1, 2, 3]) // T inferred as number[]If TypeScript can't infer it, or you just want to be explicit, you can specify the type manually with angle brackets at the call site, matching the <T> from the declaration:
identity<string>('hi') // explicitly T = stringYou rarely need to do this — only when inference fails or the arguments alone don't give TypeScript enough to go on, like when a function takes no parameters and the type can only come from the return value.
Generic constraints: not every type is welcome
A function like identity reasonably accepts any type at all, but plenty of situations call for narrowing down what's allowed. Say you're writing a function that grabs the length of whatever's passed in:
function getLength<T>(item: T) {
return item.length // error: type T has no property length
}This throws an error because T could be literally anything right now, and TypeScript has no guarantee that "anything" has a .length property. That's where generic constraints come in — use extends to tell TypeScript "T has to at least look like this":
interface HasLength {
length: number
}
function getLength<T extends HasLength>(item: T): number {
return item.length
}
getLength('hello') // fine, strings have length
getLength([1, 2, 3]) // fine, arrays have length
getLength(42) // error: number has no length propertyWith the constraint in place, T is no longer "any type" — it's "any type with a length property." That's a narrower set, but it means you can safely use .length inside the function, because TypeScript has already guaranteed it'll be there. This extends is the same keyword used for class inheritance, but the meaning is different here: it describes "a subset of types matching this shape," not an inheritance relationship.
Generics aren't just for functions
Types and interfaces can take generic parameters the same way functions do — leave the type blank in the definition, fill it in later at the point of use. The classic example is a "container" type:
interface Box<T> {
value: T
}
const numberBox: Box<number> = { value: 42 }
const stringBox: Box<string> = { value: 'hello' }Box<T> describes "a box holding some value of type T" — the shape has nothing to do with what's actually inside. Box<number> and Box<string> are just the same definition applied with different type parameters.
A more realistic example: wrapping a Result type
A common pattern for handling operations that might succeed or fail is a generic Result type:
type Result<T, E> = { success: true; data: T } | { success: false; error: E }
function parseNumber(input: string): Result<number, string> {
const parsed = Number(input)
if (Number.isNaN(parsed)) {
return { success: false, error: `Could not parse: ${input}` }
}
return { success: true, data: parsed }
}
const result = parseNumber('42')
if (result.success) {
console.log(result.data.toFixed(2)) // TypeScript knows this is a number
} else {
console.log(result.error.toUpperCase()) // TypeScript knows this is a string
}There are two type parameters here, T and E — the type of the data on success, and the type of the error on failure. Result<T, E> works for any operation, no matter what data or error actually end up being; the "success or failure" structure only needs to be written once. That's the core value of generics: separate the structure from the concrete types inside it. Write the structure once, and let the actual types get filled in based on how it's called.
Default type parameters
If E is string in most cases and only occasionally needs to be something else, you can give a type parameter a default value, the same way you'd give a function parameter one:
type Result<T, E = string> = { success: true; data: T } | { success: false; error: E }
type A = Result<number> // same as Result<number, string>
type B = Result<number, Error> // explicitly overrides the defaultWith a default in place, most call sites can skip the second type parameter entirely, and only specify it when they actually need a different error type.
Common pitfalls
-
Treating generics as just another way to say "accepts anything."
anythrows away type checking entirely; a generic defers the type decision but keeps it consistent and checked once it's made. If the same type parameter shows up multiple times in a function signature, TypeScript enforces that every one of those positions agrees with each other —anygives you no such guarantee. -
Constraints that are too loose or too tight. Too loose — skipping the constraint entirely, or only constraining to
object— leaves the function unable to safely access the properties it actually needs. Too tight — pinning the constraint to one specific concrete type — strips away the flexibility generics were supposed to give you in the first place, and you're right back to writing a separate version for every type. The rule of thumb: constrain to exactly the shape the function actually uses inside its body, nothing more, nothing less.
Takeaway
Generics solve a tension that otherwise looks unavoidable: reusability versus type safety. Without them, you're either copy-pasting near-identical code for every type, or reaching for any and giving up checking altogether in exchange for flexibility. Generics let a type itself be passed around like a parameter — the structure of a function or type only needs to be defined once, the concrete type gets decided at the point of use, and TypeScript tracks and checks the relationships between those types the whole way through. Next time you're staring at a function or type that needs to support multiple types but keeps the exact same logic, don't reach for copy-paste or any — consider giving it a <T> slot instead.