TypeScript generics are type-level parameters that capture a specific type and reuse it across a signature, preserving relationships that
any,unknown, and broad unions lose.
Drill 1: Preserve an Input-Output Relationship
A generic is a type-level parameter. The parameter receives a type argument in much the same way that a function parameter receives a value argument.
This standalone example captures the type of value as Value and returns that same type:
function identity<Value>(value: Value): Value {
return value;
}
const count = identity(3);
// ^? const count: 3
const label = identity("queued");
// ^? const label: "queued"
The useful property is not that identity accepts several types. The signature preserves a correspondence: the return type is the type captured from the argument.
Compare that with any:
function identityAny(value: any): any {
return value;
}
const result = identityAny("queued");
// ^? const result: any
result.missing.method(); // No useful compiler protection
unknown is safer than any because code must narrow an unknown value before using it. It still cannot express that the output has the same type as the input:
function identityUnknown(value: unknown): unknown {
return value;
}
const result = identityUnknown("queued");
// ^? const result: unknown
A union such as string | number retains the listed possibilities, but it loses the correspondence for a particular call. Overloads can describe a finite set of cases. A generic describes one rule that applies across the captured types.
A function should be generic when a type parameter connects meaningful positions. Common relationships include:
- An input element and the returned element
- An object and one of its keys
- An event name and its payload
- A table row and the value selected from that row
A reusable function does not automatically need a generic. If no relationship needs preservation, a concrete type is usually clearer.
Drill 2: Trace Inference Through a Signature
Names such as T, K, and V are conventions, not special syntax. Descriptive names help while learning: Row, Key, and Value reveal the role of each parameter.
A generic function places its type parameters before its value parameters:
function first<Element>(items: Element[]): Element | undefined {
return items[0];
}
const firstName = first(["Ada", "Lin"]);
// ^? const firstName: string | undefined
TypeScript infers Element from the array argument. An explicit type argument is also possible:
const firstId = first<number>([10, 20]);
// ^? const firstId: number | undefined
Explicit arguments help when inference has too little evidence. In ordinary calls, inference often produces a simpler API.
Multiple parameters express multiple roles. This standalone pair function retains both types:
function pair<Left, Right>(left: Left, right: Right): [Left, Right] {
return [left, right];
}
const entry = pair("attempts", 3);
// ^? const entry: [string, number]
Interfaces and type aliases can also accept type parameters:
interface Paginated<Item> {
items: Item[];
page: number;
hasNextPage: boolean;
}
type RequestResult<Data, ErrorValue = Error> =
| { status: "success"; data: Data }
| { status: "error"; error: ErrorValue };
const page: Paginated<{ id: number; title: string }> = {
items: [{ id: 1, title: "Generics" }],
page: 1,
hasNextPage: false,
};
const result: RequestResult<string> = {
status: "success",
data: "ready",
};
ErrorValue = Error is a generic default. It makes the second type argument optional. If a parameter also has a constraint, its default must satisfy that constraint.
A class parameter connects instance members:
class Store<Value> {
constructor(private value: Value) {}
get(): Value {
return this.value;
}
set(next: Value): void {
this.value = next;
}
}
const store = new Store({ authenticated: false });
// ^? const store: Store<{ authenticated: boolean }>
Arrays, pairs, pagination objects, result wrappers, and stores use the same mechanism. The declaration captures a type once and reuses it where the relationship matters.
Drill 3: Constrain Types Without Losing Information
A generic constraint defines the minimum structure an accepted type must have. It does not convert the argument, construct missing properties, or replace the captured type with the constraint.
This standalone function can read length because the constraint guarantees that property:
function describeLength<Value extends { length: number }>(
value: Value,
): { value: Value; length: number } {
return { value, length: value.length };
}
const description = describeLength({ length: 2, unit: "items" });
// ^? const description: {
// value: { length: number; unit: string };
// length: number;
// }
The result retains unit. Returning only { length: number } would discard information that the generic captured.
keyof produces the permitted property-key types, including string-, number-, and symbol-like keys. Index signatures may produce broad string, number, or symbol key types rather than only literals. Combined with a second generic parameter, it can connect an object to a valid property name:
function getProperty<
ObjectType,
Key extends keyof ObjectType,
>(
object: ObjectType,
key: Key,
): ObjectType[Key] {
return object[key];
}
const user = {
id: 42,
name: "Mina",
active: true,
};
const userName = getProperty(user, "name");
// ^? const userName: string
const active = getProperty(user, "active");
// ^? const active: boolean
// Without the directive, TypeScript reports:
// TS2345: Argument of type '"email"' is not assignable to
// parameter of type '"id" | "name" | "active"'.
// @ts-expect-error "email" is not a key of user
getProperty(user, "email");
Key extends keyof ObjectType limits the key to properties that exist. ObjectType[Key], called an indexed access type, produces the value type at that key. Changing "name" to "active" therefore changes the return type from string to boolean.
Trace the same inference through a realistic table-column call:
defineColumn<UserRow>()({ key: "active", render: value => ... })
│ │
│ explicit evidence │ argument evidence
▼ ▼
Row = UserRow Key = "active"
└──────────┬─────────┘
▼
value: Row[Key]
= UserRow["active"]
= boolean
return: TableColumn<UserRow, "active">
A property picker extends the same relationship to an array of keys:
type PickResult<
ObjectType,
Keys extends readonly (keyof ObjectType)[],
> = number extends Keys["length"]
? Partial<Pick<ObjectType, Keys[number]>>
: Pick<ObjectType, Keys[number]>;
function pick<
ObjectType,
const Keys extends readonly (keyof ObjectType)[],
>(
object: ObjectType,
keys: Keys,
): PickResult<ObjectType, Keys> {
const result: Partial<Pick<ObjectType, Keys[number]>> = {};
for (const key of keys) {
result[key] = object[key];
}
return result as PickResult<ObjectType, Keys>;
}
const summary = pick(user, ["id", "name"]);
// ^? const summary: Pick<
// { id: number; name: string; active: boolean },
// "id" | "name"
// >
The {} expression initializes the object; the assertion only overrides compiler checking at the return boundary. A literal key list is inferred as a tuple and produces a complete Pick, while a broad array produces Partial<Pick<...>> because the loop cannot prove that every possible key occurs. If unchecked external data enters the program, a runtime check is still required.
Drill 4: Build Frontend APIs That Keep Types Connected
Frontend code repeatedly connects data to UI configuration. Request states connect a status to its data, table columns connect a key to a cell value, and event maps connect an event name to its payload.
The following sections keep the three patterns separate so each one can be run, modified, and diagnosed independently.
type RequestState<Data, ErrorValue = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Data }
| { status: "error"; error: ErrorValue };
Table-column exercise
interface TableColumn<
Row,
Key extends keyof Row = keyof Row,
> {
key: Key;
heading: string;
render: (value: Row[Key], row: Row) => string;
}
function defineColumn<Row>() {
return function <Key extends keyof Row>(
column: TableColumn<Row, Key>,
): TableColumn<Row, Key> {
return column;
};
}
Typed-emitter exercise
interface TypedEmitter<Events extends object> {
on<Name extends keyof Events>(
name: Name,
handler: (payload: Events[Name]) => void,
): () => void;
emit<Name extends keyof Events>(
name: Name,
payload: Events[Name],
): void;
}
function createEmitter<Events extends object>(): TypedEmitter<Events> {
const listeners = new Map<
keyof Events,
Array<(payload: unknown) => void>
>();
return {
on<Name extends keyof Events>(
name: Name,
handler: (payload: Events[Name]) => void,
) {
const wrapped = (payload: unknown) => {
handler(payload as Events[Name]);
};
const handlers = listeners.get(name) ?? [];
handlers.push(wrapped);
listeners.set(name, handlers);
return () => {
const current = listeners.get(name);
if (!current) return;
const index = current.indexOf(wrapped);
if (index >= 0) current.splice(index, 1);
};
},
emit<Name extends keyof Events>(
name: Name,
payload: Events[Name],
) {
for (const handler of listeners.get(name) ?? []) {
handler(payload);
}
},
};
}
interface UserRow {
id: number;
name: string;
active: boolean;
}
const userRequest: RequestState<UserRow[]> = {
status: "success",
data: [{ id: 1, name: "Rina", active: true }],
};
const column = defineColumn<UserRow>()({
key: "active",
heading: "Status",
render: (value, row) =>
`${row.name}: ${value ? "active" : "inactive"}`,
});
// render value: boolean
// render row: UserRow
interface AppEvents {
selected: { userId: number };
searchChanged: string;
}
const events = createEmitter<AppEvents>();
const unsubscribe = events.on("selected", payload => {
const id = payload.userId;
// ^? const id: number
});
events.emit("selected", { userId: 7 });
// Without the directive, TypeScript reports:
// TS2345: Argument of type 'string' is not assignable to
// parameter of type '{ userId: number; }'.
// @ts-expect-error selected requires { userId: number }
events.emit("selected", "7");
// Without the directive, TypeScript reports:
// TS2345: Argument of type '"archived"' is not assignable to
// parameter of type 'keyof AppEvents'.
// @ts-expect-error archived is not an AppEvents key
events.emit("archived", { userId: 7 });
unsubscribe();
RequestState<Data> preserves the connection between a successful state and its data. Its discriminated union also prevents access to data until status has been narrowed to "success".
TableColumn<Row, Key> preserves two relationships. Key must be a property of Row, and the renderer receives Row[Key]. The defineColumn helper lets the row type be supplied while the selected key is inferred.
TypedEmitter<Events> connects each event name to the matching property in Events. The implementation stores handlers behind an unknown boundary, so it needs one localized assertion when the wrapper restores the event-specific type. Public calls remain checked, but this assertion is not runtime payload validation.
Follow-up Drill: Combine Generics With Type Operators
Conditional types choose a type by testing a relationship:
type AsyncResult<Value> =
Value extends Promise<infer Resolved>
? Resolved
: Value;
type LoadedUser = AsyncResult<Promise<{ id: number }>>;
// ^? type LoadedUser = { id: number }
type ImmediateCount = AsyncResult<number>;
// ^? type ImmediateCount = number
infer Resolved introduces a type variable inside the successful branch. Here it extracts the value carried by Promise.
The same pattern can extract an array element:
type ElementType<Value> =
Value extends readonly (infer Element)[]
? Element
: never;
type Name = ElementType<readonly string[]>;
// ^? type Name = string
type InvalidElement = ElementType<Date>;
// ^? type InvalidElement = never
A mapped type iterates over a union of property keys and builds another type. This handler type converts each event payload into a matching callback:
type EventHandlers<Events extends object> = {
[Name in keyof Events]:
(payload: Events[Name]) => void;
};
interface DialogEvents {
opened: { source: "button" | "keyboard" };
closed: { confirmed: boolean };
}
const handlers: EventHandlers<DialogEvents> = {
opened(payload) {
const source = payload.source;
// ^? const source: "button" | "keyboard"
},
closed(payload) {
const confirmed = payload.confirmed;
// ^? const confirmed: boolean
},
};
The mapped type keeps each key attached to its own payload. Replacing Events[Name] with any would keep the property names while removing the relationship that makes the handlers useful.
Conditional types, infer, and mapped types are frequent follow-ups after basic generic functions. The Type Utilities exercises provide focused compiler checks, while Type Utilities II extends the same ideas into more demanding transformations.
Debug the Mistakes Interviewers Look For
Plausible generic signatures often compile while expressing the wrong contract.
| Mistake | Plausible signature | What the signature loses | Better decision |
|---|---|---|---|
| Unnecessary parameter | function log<Value>(value: Value): void | Value connects no useful positions | Accept unknown if the function only observes a value |
| Broad return type | function first<Value>(xs: Value[]): unknown | The element and return types are disconnected | Return Value | undefined |
| Replaced relationship | function get(obj: any, key: string): any | Neither valid keys nor property values are checked | Use Key extends keyof ObjectType and ObjectType[Key] |
| Constraint returned as result | function keep<Value extends { id: number }>(x: Value): { id: number } | Extra properties disappear from the declared result | Return Value if the same value is returned |
| Unsupported promise | function create<Value>(): Value | No argument or callback provides a Value | Require evidence, such as a value or factory |
A useful review rule is to count where each type parameter appears. A parameter used once may not express a relationship. TypeScript's guidance is to use as few type parameters as possible and reconsider parameters that appear in only one location.
Assertions deserve similar scrutiny. This implementation lies about its result:
function empty<Value>(): Value {
return {} as Value;
}
const account = empty<{ id: number }>();
// ^? const account: { id: number }
// Compiles, but no runtime code created id.
Generics cannot inspect or create a runtime Value. TypeScript removes types when producing JavaScript. Runtime validation needs runtime evidence, such as a predicate, schema, or constructor passed as a value.
A class type parameter also belongs to its instances, not its static members:
class Cache<Value> {
// Invalid: static members cannot reference Value
// static initial: Value;
constructor(public current: Value) {}
}
There is one runtime static property for the class, while separate instances may use different type arguments. A static member therefore cannot use the class's generic parameter.
Run the Interview Exercises
The examples below are checked with TypeScript 5.9.3. Save this configuration as tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true
},
"include": ["generics.test.ts"]
}
Run the suite with:
npx -p [email protected] tsc -p tsconfig.json
Start generics.test.ts with checked type assertions:
type Equal<Left, Right> =
(<Value>() => Value extends Left ? 1 : 2) extends
(<Value>() => Value extends Right ? 1 : 2)
? true
: false;
type Expect<Value extends true> = Value;
Generic-function tests
const populated = first(["Ada", "Lin"]);
type PopulatedCheck = Expect<
Equal<typeof populated, string | undefined>
>;
const empty = first([]);
type EmptyCheck = Expect<Equal<typeof empty, undefined>>;
function firstUnknown(items: unknown[]): unknown {
return items[0];
}
const lostRelationship = firstUnknown(["Ada", "Lin"]);
type UnknownCheck = Expect<Equal<typeof lostRelationship, unknown>>;
unknown is safe to receive, but it loses the relationship between the array element and the return value.
Request-state tests
interface DrillRow {
id: number;
name: string;
active: boolean;
}
const loaded: RequestState<DrillRow[]> = {
status: "success",
data: [{ id: 1, name: "Rina", active: true }],
};
if (loaded.status === "success") {
type DataCheck = Expect<
Equal<typeof loaded.data, DrillRow[]>
>;
}
// @ts-expect-error success requires data
const incomplete: RequestState<DrillRow[]> = {
status: "success",
};
Table-column tests
const activeColumn = defineColumn<DrillRow>()({
key: "active",
heading: "Status",
render: (value, row) => {
type ValueCheck = Expect<Equal<typeof value, boolean>>;
type RowCheck = Expect<Equal<typeof row, DrillRow>>;
return `${row.name}: ${value ? "active" : "inactive"}`;
},
});
type ColumnCheck = Expect<
Equal<typeof activeColumn, TableColumn<DrillRow, "active">>
>;
defineColumn<DrillRow>()({
// @ts-expect-error "email" is not a DrillRow key
key: "email",
heading: "Email",
render: value => String(value),
});
Typed-emitter tests
interface DrillEvents {
selected: { userId: number };
searchChanged: string;
}
const drillEvents = createEmitter<DrillEvents>();
drillEvents.on("selected", payload => {
type PayloadCheck = Expect<
Equal<typeof payload, { userId: number }>
>;
});
drillEvents.emit("selected", { userId: 7 });
// @ts-expect-error selected requires { userId: number }
drillEvents.emit("selected", "7");
// @ts-expect-error archived is not a DrillEvents key
drillEvents.emit("archived", { userId: 7 });
The Type Utilities editor and tests, Type Utilities II editor and tests, and Mini React Query Core editor and tests provide longer versions of these drills.
Interviewer follow-ups can raise the difficulty without changing the subject:
- Add a default error type to
RequestState. - Preserve a selected table key through
Row[Key]. - Extract a resolved value with a conditional type and
infer. - Explain where runtime validation would enter the API.
- Remove any generic parameter that does not connect two meaningful positions.
Score each solution with this rubric:
| Dimension | Fail | Pass | Strong |
|---|---|---|---|
| Correctness | Fails strict checks or accepts invalid calls | Compiles under strict checks and rejects the tested invalid calls | Also preserves every required input-output relationship |
| Inference quality | Requires any, casts, or repeated explicit type arguments | Infers useful return, key, and payload types | Keeps literal and indexed-access information wherever the call provides evidence |
| API ergonomics | Call sites are unclear or annotation-heavy | Valid calls are readable with little annotation | Errors point to the incorrect key, payload, or state at the call site |
| Edge cases | Ignores empty arrays, invalid keys, or incomplete states | Covers the stated positive and negative cases | Identifies assertion and runtime-validation boundaries explicitly |
| Explanation | Recites syntax without tracing a relationship | Names the evidence that binds each type parameter | Traces each parameter into the result and explains where static guarantees end |
The broader JavaScript coding interview guide helps place these drills inside a complete practice session. Longer editor access and additional test-backed exercises are available with UIReady Premium Lifetime.