30% offEnding soon

TypeScript Generics: Frontend Interview Guide

24 min read

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.

input“queued”genericsame literaltypeinput“queued”anyidentity lostuncheckedinput“queued”unknownidentity lostmust narrow
Generics preserve the input’s exact type across the call.

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.

captured Valuerequired minimumlength: numberextra detailunit: stringresult keeps length + unit
A constraint checks the minimum while the generic retains the whole type.

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.

keyof useruser[key]idnumbernamestringactivebooleanemail×not in keyof user
The selected key determines the indexed-access return type.

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".

checkstatusidleloadingsuccesserrordataunlockedno datano datahas error
Checking the status opens only the fields available on that branch.

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.

Row = UserRowidnumbernamestringKey = activebooleanvalue: booleanrender receivesthe selected typeother row fields stay available as row
A column key selects one value type from the full row.

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.

event namepayload typeselecteduserIdnumbersearchChangedstringarchived×no matching wire
Each event name is wired to exactly one payload type.

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.

does Value match Promise?Promise wrapperinfer innerid: numberresolved typeid: numbernumbernumberotherwise unchanged
A conditional type unwraps a Promise only on the matching branch.

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.

bare generic: union splitsstring[]or number[]test 1test 2stringnumbertuple wrapped: union stays whole[ string[]or number[] ]one testwhole
Tuple wrapping changes a conditional from per-member tests to one union-wide test.

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.

MistakePlausible signatureWhat the signature losesBetter decision
Unnecessary parameterfunction log<Value>(value: Value): voidValue connects no useful positionsAccept unknown if the function only observes a value
Broad return typefunction first<Value>(xs: Value[]): unknownThe element and return types are disconnectedReturn Value | undefined
Replaced relationshipfunction get(obj: any, key: string): anyNeither valid keys nor property values are checkedUse Key extends keyof ObjectType and ObjectType[Key]
Constraint returned as resultfunction keep<Value extends { id: number }>(x: Value): { id: number }Extra properties disappear from the declared resultReturn Value if the same value is returned
Unsupported promisefunction create<Value>(): ValueNo argument or callback provides a ValueRequire 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.

compile timeTypeScriptidentity<Value>ValuetypeerasedJavaScriptidentity(value)schema valueor predicateruntime canvalidate
Generic types disappear at compilation; runtime evidence must remain 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.

separate instancesCache<string>current: stringCache<number>current: numberone sharedstatic propertywhich Value?static belongs to the classnot either instance
Many generic instances cannot lend their different types to one shared static member.

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:

DimensionFailPassStrong
CorrectnessFails strict checks or accepts invalid callsCompiles under strict checks and rejects the tested invalid callsAlso preserves every required input-output relationship
Inference qualityRequires any, casts, or repeated explicit type argumentsInfers useful return, key, and payload typesKeeps literal and indexed-access information wherever the call provides evidence
API ergonomicsCall sites are unclear or annotation-heavyValid calls are readable with little annotationErrors point to the incorrect key, payload, or state at the call site
Edge casesIgnores empty arrays, invalid keys, or incomplete statesCovers the stated positive and negative casesIdentifies assertion and runtime-validation boundaries explicitly
ExplanationRecites syntax without tracing a relationshipNames the evidence that binds each type parameterTraces 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.

Frequently asked questions

What are generics in TypeScript?
Generics are type parameters that preserve relationships between values and types. A generic function can capture an argument type and reuse that exact type in its return type.
When should a TypeScript function be generic?
A function should be generic when its signature needs to connect two or more type positions, such as an input type and a return type. A type parameter that appears only once often adds no useful relationship.
What does extends mean in a generic constraint?
The extends clause states the minimum structure a type argument must satisfy. It does not convert the argument or replace its specific type with the constraint.
Do TypeScript generics exist at runtime?
No. TypeScript removes types when it produces JavaScript, so a generic type parameter cannot perform runtime validation or select a runtime constructor.