Generics use type parameters to describe relationships across several positions, so one implementation can serve many concrete types without losing call-specific type information.
T exists only while TypeScript checks code; as T, any, or unvalidated JSON can make a generic signature promise a result that runtime code never proved.
Make each type parameter connect at least two meaningful positions, prefer inference, and pass a constructor or validator when runtime evidence is required.
What it is and why it exists
A generic is a declaration written with placeholder types.
Each placeholder is a type parameter , commonly named T, TKey, or TValue.
A concrete type fills that parameter when you call a function, construct a class, or reference a type alias.
It records the relationship that one as-yet-unknown type must preserve across several positions, rather than merely accepting arbitrary values.
For example, (value: T) => T says that the result has the same type as the input.
(items: T[]) => T | undefined says that a result, when present, has the array’s element type.
Replacing those positions with any might leave working JavaScript, but callers can no longer learn the input-output relationship from the signature.
Copying the implementation for string, number, and every domain object creates another problem: identical logic starts to diverge.
You’ll meet generics in array methods, promises, collections, React component props, API clients, and library functions.
Standard types such as Array<T>, Map<TKey, TValue>, and Promise<T> use parameters to record the relationship between a container and its contents.
Application code often needs the same tool for paginated responses, repositories, and event maps.
Generics are static. They let the checker reject inconsistent calls and preserve precise editor completions, but they don’t validate a network response or create type objects in JavaScript. A runtime value from JSON, a database driver, or an untyped dependency still needs validation.
How it works
In function identity<T>(value: T): T, the scope of T covers the parameter type, return type, and type positions inside the function body.
When you call identity("ready"), the checker collects candidates from the argument and uses type inference to choose T for that call.
You normally don’t need identity<string>("ready"); explicit type arguments are useful when inference lacks information or when you deliberately want to pin an API contract.
A generic declaration can have several type parameters.
Map<TKey, TValue> records keys and values separately because nothing says they must have the same type.
Names should expose roles: T and U are fine in a short, local relationship, while TInput and TResult make a public API with several roles easier to read in diagnostics.
A generic constraint uses extends to restrict the types that may be substituted.
In TKey extends keyof TObject, TKey can only be one of TObject’s known keys.
The key parameter and TObject[TKey] return type then stay correlated: passing "id" produces the id property’s type, not a broad union of every property type.
Constraints check capabilities under TypeScript’s structural type system.
T extends { length: number } means that a value must have a numeric length property; it needn’t be an instance of a particular class.
The constraint also lets the function body use the declared members safely, but no members beyond them.
Type parameters may have defaults, as in Result<TValue, TError = Error>.
A default removes annotations in the common case, but it doesn’t override a candidate that inference already found.
When a public API defaults to any, omitted arguments lose checking, so unknown, a domain default, or no default is usually more honest.
The checker handles a generic call roughly along this path.
The diagram describes the static phase; the emitted JavaScript doesn’t retain T.
Generic functions, interfaces, type aliases, and classes can all declare type parameters, but ownership differs.
interface Box<T> fixes T across the instance contract; interface Converter { convert<T>(value: T): T } lets each method call choose its own T.
Putting the parameter at the wrong level can force otherwise independent calls to share one overly broad type choice.
Examples
Carry inference from input to output
firstMatch only needs an element type, and it places the same T in the array, predicate parameter, and return type.
Once a caller supplies Product[], the predicate’s product and the result both retain the Product type.
type Product = {
sku: string;
name: string;
stock: number;
};
function firstMatch<T>(
items: readonly T[],
accepts: (item: T) => boolean,
): T | undefined {
return items.find(accepts);
}
const products: Product[] = [
{ sku: "P-101", name: "mouse", stock: 0 },
{ sku: "P-102", name: "keyboard", stock: 7 },
];
const available = firstMatch(products, (product) => product.stock > 0);
console.log(available?.sku, available?.name);
console.log(firstMatch(products, (product) => product.stock > 10));P-102 keyboard
undefinedreadonly T[] says that the function only reads the collection.
It therefore accepts mutable and readonly arrays, and its body can’t accidentally mutate the caller’s data.
The return type keeps undefined because the type relationship can’t prove that a match exists.
This example needs no explicit <Product>.
If a caller supplies a wider type argument, that choice can hide an input problem that inference would otherwise expose.
Let the compiler infer first; pin an argument only when diagnostics or an API boundary actually require it.
Establish safe access with a key constraint
The next two type parameters aren’t independent placeholders.
TKey is constrained by keyof TObject, while the indexed access type TObject[TKey] computes the matching property’s value type.
function getProperty<TObject, TKey extends keyof TObject>(
object: TObject,
key: TKey,
): TObject[TKey] {
return object[key];
}
const shipment = {
id: "S-204",
attempts: 2,
delivered: false,
};
const shipmentId = getProperty(shipment, "id");
const attempts = getProperty(shipment, "attempts");
console.log(shipmentId.toLowerCase());
console.log(attempts + 1);s-204
3shipmentId is a string, and attempts is a number.
Changing the second argument to "owner" fails at the call because that literal isn’t part of keyof typeof shipment.
This is more precise than returning string | number | boolean, and safer than asserting the result to a desired type.
keyof only describes keys known to the static type.
It doesn’t prove that a runtime object has no extra properties, and it doesn’t automatically turn the result of Object.keys() into (keyof T)[].
A general tool that iterates every key must deal with that difference explicitly.
Preserve a relationship across a generic class
A function’s type parameters usually last for one call.
A class’s parameters belong to an instance; after creating Registry<number, Job>, every set and get on that instance follows the same key-value relationship.
class Registry<TKey, TValue> {
readonly #entries = new Map<TKey, TValue>();
set(key: TKey, value: TValue): void {
this.#entries.set(key, value);
}
get(key: TKey): TValue | undefined {
return this.#entries.get(key);
}
}
type Job = {
owner: string;
state: "queued" | "running";
};
const jobs = new Registry<number, Job>();
jobs.set(17, { owner: "Lin", state: "queued" });
console.log(jobs.get(17));
console.log(jobs.get(99));{ owner: 'Lin', state: 'queued' }
undefinedThis class wraps a Map without pretending that get always succeeds.
When a key is missing, the real runtime result is undefined, so the signature retains it.
Generated code often erases this branch with a non-null assertion; that only silences the checker and can’t create the missing value.
A class’s static members can’t directly use an instance type parameter.
Static members belong to the constructor itself, not to a particular Registry<number, Job> instance.
If a static factory needs generics, it declares its own parameters and returns the corresponding instance type.
Pass runtime evidence to a generic function
Type parameters are erased, so parseJson<T> can’t decide whether JSON is valid from T alone.
This version also receives a type predicate; that function is runtime evidence and the only route from unknown to T.
type Validator<T> = (value: unknown) => value is T;
function parseJson<T>(text: string, isValid: Validator<T>): T {
const value: unknown = JSON.parse(text);
if (!isValid(value)) {
throw new Error("invalid payload");
}
return value;
}
type FeatureFlag = { name: string; enabled: boolean };
const isFeatureFlag: Validator<FeatureFlag> = (
value,
): value is FeatureFlag =>
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string" &&
"enabled" in value &&
typeof value.enabled === "boolean";
const flag = parseJson(
'{"name":"new-checkout","enabled":true}',
isFeatureFlag,
);
console.log(`${flag.name}: ${flag.enabled}`);new-checkout: trueThe generic relationship ensures that the validator’s accepted type matches the function’s return type.
But a type predicate is still a proof written by a programmer; the checker doesn’t verify that its logic is complete.
Test validators with wrong field types, null, missing fields, and every extra domain constraint instead of trusting a polished signature.
The same “pass the evidence” pattern works for construction.
If a function needs to create T, it can receive a constructor signature of the form new (...args) => T; if it needs to parse T, it can receive a validator or schema object.
The generic preserves the relationship, while the supplied value provides runtime behavior.
Pitfalls
Using any to imitate a relationship
Fix: If the implementation really returns its input unchanged, use <T>(value: T) => T.
If the input may be any value but requires checking before use, make it unknown.
Write down which positions must agree before deciding how many type parameters you need.
Manufacturing a result with as T
Such a function often looks like a general parser, but its real contract is “trust me.”
A caller who writes <Account> gets full completion even when the runtime object has no id.
The failure moves away from the boundary and into later application code.
Fix: Start from unknown and require the caller to provide a validator, schema, or constructor.
Keep an assertion only at a narrow boundary already proved by another mechanism, and state what that proof is.
When you can’t prove it, returning unknown is more accurate than returning a fictional T.
Mistaking an extends constraint for validation
A constraint answers “which static members may the function body rely on?” It doesn’t answer “does this runtime value satisfy the business rules?” Even the right structure may contain an empty, malformed, or unauthorized ID.
Fix: Validate shape and domain constraints at the external boundary, then pass the value into the generic core. Make the constraint describe the smallest capability that core algorithm needs. Don’t widen it to a large business interface merely to access unrelated fields conveniently.
Declaring a type parameter that appears once
function logValue<T>(value: T): void generally says no more than function logValue(value: unknown): void.
By contrast, a T that appears in the input and output, or connects a collection element to a callback parameter, gives callers useful information.
Fix: Check that each parameter connects at least two meaningful positions or is constrained by another parameter.
Use a concrete type when the function accepts one fixed category of values.
Use unknown when the value is genuinely unknown and the function doesn’t rely on its structure.
Hiding inference with explicit type arguments
The same T also doesn’t mean that two arguments must have exactly the same literal type.
The checker searches all inference sites for an acceptable candidate; the result may be a common supertype, a widened type, or a union.
When a domain needs strict equality, model that state directly and write type tests instead of deriving extra guarantees from a repeated letter.
Fix: Let call arguments drive inference by default, and inspect the actual result type shown by the editor.
Pass explicit type arguments only when inference lacks information, a return contract must be fixed, or an empty-collection API needs a type.
Use @ts-expect-error in negative type tests for calls that must remain rejected.
Ignoring readonly inputs and TSX parsing
Both failures appear when a model mechanically rewrites an ordinary function as a generic arrow. The type relationship may be right while the calling surface and parse environment get worse.
Fix: Use readonly T[] or ReadonlyArray<T> for read-only inputs.
In TSX, write a single parameter as <T,> or use a function declaration to remove the ambiguity.
Run the checker under the project’s real .tsx configuration after the change instead of inspecting an isolated .ts snippet.
Type erasure and runtime witnesses
Type erasure means that type parameters, interfaces, and type aliases don’t become JavaScript values.
A function body therefore can’t evaluate value instanceof T, call new T() directly, or read T.someStaticMember.
Those expressions need a runtime value on the right or at the call target, while T belongs only to the checker.
When runtime work is required, put the corresponding evidence in a value parameter. A constructor argument lets a function create an instance, a type predicate or schema object lets it validate unknown input, and a tagged object lets code distinguish members in branches. This doesn’t bypass generics; the generic relationship keeps “the type covered by the evidence” aligned with “the type the function promises.”
A runtime witness has its own trust boundary.
A function declared as (value: unknown) => value is User can simply return true, and the checker will still accept it.
Validators therefore need runtime tests, constructors need to enforce real invariants, and a schema’s parsed output should drive the static type.
Generics connect evidence to results; they don’t prove that the evidence was implemented correctly.
Erasure also explains why a generic alone normally doesn’t alter one function’s runtime path.
identity<string> and identity<number> ultimately call the same JavaScript logic.
If a program must select a serialization strategy by type, pass an explicit tag, strategy function, or object instead of expecting T to appear in a runtime branch.
The information limits of inference
Inference depends on information actually present at the call site. Argument values, contextual callback types, and some return positions contribute candidates, but “what I hope to use later” doesn’t become evidence by itself. Empty arrays, argument-free factories, and parameters used only in return positions often provide too little information; those cases may need an explicit type argument or a better API shape.
Be especially suspicious of a return type shaped like T when no parameter mentions T.
Unless the function also receives a constructor, validator, or another value capable of producing T, the implementation can usually only throw, never return, or fabricate the result with an assertion.
This is a useful fast check when reading a generated generic helper.
Inference also widens and combines candidates.
A string literal may become string, several sites may lead to a union or common structure, and a constraint may affect the acceptable candidate.
Public libraries should test representative calls and state which literals must be preserved; a declaration that looks “very generic” doesn’t guarantee a good calling experience.
An explicit type argument is a contract choice, not a conversion.
Writing load<Account>() doesn’t turn an object into an Account, and identity<string>(value) doesn’t convert a runtime value to a string.
The checker reports an incompatible argument, but if the implementation has already escaped through an assertion, the explicit argument can make a false promise look even more convincing.
Ownership of a generic signature
Putting a type parameter on a call signature lets each call choose independently. Putting it on an interface or class name lets a consumer choose one relationship first, after which all members obey that choice. Callback libraries, repositories, and state containers often hinge on this distinction.
For example, a general converter method can select a new TInput for each value.
The save, find, and delete operations of Repository<Entity> should instead work around the same Entity, so the parameter belongs on the interface.
Moving Entity onto every method by mistake could let unrelated types enter the same repository instance.
A generic class only describes its instance side.
Static fields are shared by every instance, so they can’t refer to the instance’s T; a static factory declares parameters of its own.
Likewise, a constructor is a runtime value while an instance type is a static description, so an API that accepts a “class” usually needs to account for both the construct signature and the instance result.
Ask one concrete design question: who selects this type parameter, and how long does that choice last?
If the answer is “each caller, once per call,” put it on the function or method.
If it is “when this client or container is created, then shared by its members,” put it on the interface, type alias, or class.
That ownership question does more for API clarity than naming every parameter T by reflex.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug