Imagine you have a box with a label slot on the front. The box can hold a toy, a book, or a tool, but the label tells you what kind of thing is supposed to go inside. That way, you can reuse the same box design without rebuilding a new box for every object.
Generics are like that label slot for code. They let you write one function or type that works for many shapes of data, while still keeping the rules clear so you don’t accidentally put the wrong thing in the box.
Reusable code that stays type-safe
Generics let you describe relationships between values and types without choosing the exact type up front. They are useful when the logic is the same, but the data type changes depending on the caller.
This matters because you get two things at once: reusability and type safety. Instead of writing nearly identical functions for string, number, or custom objects, you write one generic version and let the compiler check that each use is consistent.
Generics show up in two places:
Generic functions: the function adapts to the caller’s type.
Generic types: the container, object, or alias is parameterized by type.
You can also add constraints with extends to say “this type must at least have these properties,” and defaults to make a type parameter optional when a common case exists.
Generic functions: type parameters travel with the call
A generic function declares a type parameter like <T> and uses it in the parameter and return types. The important idea is that T is not a runtime value; it is a compile-time placeholder that gets fixed separately for each call.
This lets the function preserve the caller’s type information instead of widening it to something vague like any. The classic pattern is “input of type T, output of type T,” which guarantees the function doesn’t silently change the kind of thing it received.
Because the type is tied to the call, each invocation can use a different T. One call can treat T as string, another as { id: number }, and the same function body still works.
function identity<T>(value: T): T {
return value;
}
const a = identity("hello"); // string
const b = identity(42); // number
Generic types: parameterized shapes and containers
A generic type applies the same idea to aliases, interfaces, or classes. Instead of hard-coding a property type, you let the type itself accept a type argument.
This is especially useful for containers and wrappers, where the structure is fixed but the contents vary. For example, a Box<T> can represent a box of string, a box of User, or a box of anything else, while still preserving the exact element type.
A generic type usually makes the relationship explicit in the shape of the data. If a field stores T, then every consumer of that type knows exactly what kind of value to expect, without needing casts.
Constraints with `extends`: allow only types with needed capabilities
A constraint narrows a type parameter to a family of allowed types. In TypeScript-style generics, T extends X means T must be assignable to X, so the generic code can safely rely on the members of X.
This is how you avoid writing unsafe code inside a generic. If your function needs a .length, then unconstrained T is too broad; a constraint like T extends { length: number } tells the compiler that length is always available.
Interviewers often test whether you understand that extends here is not inheritance in the everyday OO sense. It is a type requirement, not a promise that the runtime object “inherits” from something.
function logLength<T extends { length: number }>(value: T): number {
return value.length;
}
logLength("abc");
logLength([1, 2, 3]);
Defaults and inference: let callers omit what the compiler can figure out
A default type parameter supplies a fallback when the caller does not explicitly choose a type argument. This is useful when one type is common and you want a clean API for the typical case.
Type inference is the compiler’s ability to infer T from the values you pass in. In many cases, you do not need to write <T> at all because the parameter types already reveal it.
The practical rule is: prefer inference when it is clear, use explicit type arguments when the compiler cannot infer enough, and use defaults when the API has a sensible “common path” that should require less syntax.
type Result<T = string> = {
value: T;
};
const r1: Result = { value: "ok" }; // T defaults to string
const r2: Result<number> = { value: 10 }; // explicit override
function first<T>(items: T[]): T {
return items[0];
}
const x = first(["a", "b"]); // inferred as string
A realistic reusable API: cache wrapper
Suppose you want a small cache entry type that can store any payload, plus a helper that reads fields safely when they exist. A generic type models the entry, and a constrained function works only for entries that have a key.
This keeps the API flexible without losing correctness. A caller can store string, number, or an object payload, and the compiler still knows exactly what comes back out.
You also get good ergonomics:
The payload type is often inferred from the value you pass.
A default can cover the most common payload shape.
A constraint can protect helpers that need shared properties.
Inference can be enough, but only from available clues
A generic function usually does not need explicit type arguments if the compiler can infer them from the inputs. That makes APIs feel natural at the call site, especially for simple transformations and identity-like operations.
The key limitation is that inference only works from what is present in the call. If the function has no value position that reveals T, or if the value is too broad, you may need an explicit type argument or a better constraint.
This is why good generic APIs are designed so the type parameter appears in input positions when possible, because that gives inference something to work with.
function wrap<T>(value: T) {
return { value };
}
const a = wrap(123); // T inferred as number
const b = wrap({ x: 1 }); // T inferred as { x: number }
function makeEmpty<T>(): T[] {
return [];
}
// makeEmpty() gives little inference help; callers often need context.
Common traps interviewers look for
A frequent mistake is treating a generic type parameter like a runtime variable. T does not exist at runtime; it only guides compile-time checking, so you cannot branch on it as if it were a real value.
Another trap is overusing any when a generic would preserve relationships between inputs and outputs. If two values are meant to stay linked, any erases that link and hides bugs.
Watch for these interview traps:
Assuming extends means classic inheritance instead of a constraint.
Expecting inference to work when the type parameter never appears in an input position.
Forgetting that defaults only apply when the caller omits the type argument.
Using too-wide constraints and then being surprised that safe members are not available.
When explaining generics, always say what is being parameterized, what is inferred, and what the constraint guarantees.