TypeScript Generics Practice Programs with Solutions (30 Programs)

Practise TypeScript generics with 30 programs, each with a full solution, the exact output and a short explanation. The programs start with the basics, move to intermediate problems and finish with real-world scenarios from test automation, such as test data, prices, form inputs and test results. They are written for testers learning TypeScript for Playwright.

Advertisement

How to practise: read the task, write your own solution first, then open Show solution to compare. Run any program with npx tsx program.ts (or compile it with npx tsc).

New to this topic? Read the TypeScript Generics lesson first.

TypeScript Beginner Programs

1. Generic identity functionBeginner

Write identity<T> and call it with two different types.

Show solution
function identity<T>(value: T): T {
  return value;
}
const n = identity(42);
const s = identity('login');
console.log(n + 1, s.toUpperCase());

Output

43 LOGIN

Explanation: T is filled in from the argument, so n is a number and s is a string without casts.

2. Explicit type argumentBeginner

Pass the type argument yourself with angle brackets.

Show solution
function wrap<T>(value: T): T[] {
  return [value];
}
const ids = wrap<number>(7);
const tags = wrap<string>('@smoke');
console.log(ids, tags);

Output

[ 7 ] [ '@smoke' ]

Explanation: usually TypeScript infers T, but you can write it explicitly when you want to be clear or force a wider type.

3. Generic function rejects mismatched argumentsBeginner

Show that both arguments must share the same T.

Show solution
function pair<T>(a: T, b: T): [T, T] {
  return [a, b];
}
console.log(pair(1, 2));
// @ts-expect-error: Argument of type 'string' is not assignable to parameter of type 'number'
console.log(pair(1, 'two'));

Output

[ 1, 2 ]
[ 1, 'two' ]

Explanation: once T is inferred as number from the first argument, the second must be a number too.

4. First item of any arrayBeginner

Write first&lt;T&gt;(items: T[]): T | undefined.

Show solution
function first<T>(items: T[]): T | undefined {
  return items[0];
}
console.log(first(['chromium', 'firefox']));
console.log(first([200, 404]));
console.log(first([]) ?? 'none');

Output

chromium
200
none

Explanation: the return type follows the element type, and undefined covers the empty array.

5. Generic array typeBeginner

Use Array&lt;T&gt; style in a function that reverses a copy.

Show solution
function reversed<T>(items: Array<T>): Array<T> {
  return [...items].reverse();
}
const steps = ['open', 'login', 'logout'];
console.log(reversed(steps).join(' <- '), steps[0]);

Output

logout <- login <- open open

Explanation: copying before reverse keeps the caller’s array unchanged, and the element type is preserved.

6. Generic interfaceBeginner

Describe a box that holds any type of value.

Show solution
interface Box<T> {
  value: T;
  label: string;
}
const timeout: Box<number> = { value: 30000, label: 'timeout' };
const browser: Box<string> = { value: 'webkit', label: 'browser' };
console.log(`${timeout.label}=${timeout.value}, ${browser.label}=${browser.value}`);

Output

timeout=30000, browser=webkit

Explanation: a generic interface is a template for types. Box&lt;number&gt; and Box&lt;string&gt; are two concrete versions.

7. Generic type aliasBeginner

Define Maybe&lt;T&gt; and use it for an optional lookup.

Show solution
type Maybe<T> = T | null;
function findUser(id: number): Maybe<string> {
  return id === 1 ? 'ana' : null;
}
console.log(findUser(1), findUser(2));

Output

ana null

Explanation: a generic alias saves repeating patterns like T | null for every type.

8. Generic classBeginner

Build a typed queue class.

Show solution
class Queue<T> {
  private items: T[] = [];
  enqueue(item: T): void { this.items.push(item); }
  dequeue(): T | undefined { return this.items.shift(); }
  get size(): number { return this.items.length; }
}
const jobs = new Queue<string>();
jobs.enqueue('run smoke');
jobs.enqueue('run regression');
console.log(jobs.dequeue(), jobs.size);

Output

run smoke 1

Explanation: the class works for any element type, and each instance is locked to the type given when it is created.

9. Two type parametersBeginner

Write toPair&lt;K, V&gt;(key, value) that returns a tuple.

Show solution
function toPair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
const [k, v] = toPair('retries', 3);
console.log(`${k.length} chars, value ${v * 2}`);

Output

7 chars, value 6

Explanation: each parameter gets its own type variable, so k is a string and v a number.

10. Promise genericBeginner

Type an async function that resolves to a list of names.

Show solution
async function loadNames(): Promise<string[]> {
  return ['ana', 'ben'];
}
const names = await loadNames();
console.log(names.map(n => n.toUpperCase()).join(', '));

Output

ANA, BEN

Explanation: Promise&lt;T&gt; describes what await will give you, so names is a string[].

TypeScript Intermediate Programs

11. Constraint with extendsIntermediate

Accept anything that has a length property.

Show solution
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
console.log(longest('login', 'checkout'));
console.log(longest([1, 2], [1, 2, 3]).length);
// @ts-expect-error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'
const bad = () => longest(10, 20);
console.log(typeof bad);

Output

checkout
3
function

Explanation: extends { length: number } lets the body use .length and rejects types that do not have it.

12. keyof constraintIntermediate

Write pluck(items, key) that collects one property from every object.

Show solution
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(i => i[key]);
}
const tests = [
  { name: 'login', ms: 820 },
  { name: 'cart', ms: 1400 },
];
console.log(pluck(tests, 'name').join(', '));
console.log(pluck(tests, 'ms').reduce((a, b) => a + b, 0));

Output

login, cart
2220

Explanation: K extends keyof T limits the key, and T[K][] is the exact type of the collected values.

13. Default type parameterIntermediate

Give a generic response type a default payload type.

Show solution
interface ApiResponse<T = unknown> {
  status: number;
  body: T;
}
const raw: ApiResponse = { status: 204, body: null };
const typed: ApiResponse<{ id: number }> = { status: 201, body: { id: 5 } };
console.log(raw.status, typed.body.id);

Output

204 5

Explanation: T = unknown means callers can omit the type argument, and the body then has to be checked before use.

14. Generic map functionIntermediate

Write your own mapAll&lt;T, U&gt; that changes element types.

Show solution
function mapAll<T, U>(items: T[], fn: (item: T, index: number) => U): U[] {
  const out: U[] = [];
  items.forEach((item, i) => out.push(fn(item, i)));
  return out;
}
const labels = mapAll([1200, 300], (ms, i) => `#${i + 1}: ${ms}ms`);
console.log(labels.join(' | '));

Output

#1: 1200ms | #2: 300ms

Explanation: U is inferred from the callback’s return value, so labels is a string[].

15. Generic filter with a type predicateIntermediate

Write keep that narrows the result type with a predicate.

Show solution
function keep<T, S extends T>(items: T[], test: (item: T) => item is S): S[] {
  const out: S[] = [];
  for (const item of items) if (test(item)) out.push(item);
  return out;
}
const mixed: (string | number)[] = ['a', 1, 'b', 2];
const strings = keep(mixed, (x): x is string => typeof x === 'string');
console.log(strings.map(s => s.toUpperCase()).join(''));

Output

AB

Explanation: the predicate’s item is S type flows into the return type, so the result is string[], not the mixed union.

16. Generic cache classIntermediate

Cache values by key with a generic Cache&lt;K, V&gt;.

Show solution
class Cache<K, V> {
  private store = new Map<K, V>();
  getOrSet(key: K, make: () => V): V {
    if (!this.store.has(key)) this.store.set(key, make());
    return this.store.get(key) as V;
  }
}
let calls = 0;
const tokens = new Cache<string, string>();
const t1 = tokens.getOrSet('admin', () => { calls++; return 'tok-1'; });
const t2 = tokens.getOrSet('admin', () => { calls++; return 'tok-2'; });
console.log(t1, t2, calls);

Output

tok-1 tok-1 1

Explanation: the class works for any key and value types, and the factory runs only on the first request for a key.

17. Generic Result typeIntermediate

Return either data or an error without throwing.

Show solution
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parseNumber(text: string): Result<number> {
  const n = Number(text);
  return Number.isNaN(n) ? { ok: false, error: `not a number: ${text}` } : { ok: true, value: n };
}
for (const t of ['42', 'abc']) {
  const r = parseNumber(t);
  console.log(r.ok ? r.value * 2 : r.error);
}

Output

84
not a number: abc

Explanation: the ok flag narrows the union, so value is only reachable on success.

18. Generic constraint on an interfaceIntermediate

Sort any objects that have an id.

Show solution
interface HasId {
  id: number;
}
function sortById<T extends HasId>(items: T[]): T[] {
  return [...items].sort((a, b) => a.id - b.id);
}
const bugs = [{ id: 3, title: 'crash' }, { id: 1, title: 'typo' }];
console.log(sortById(bugs).map(b => `${b.id}:${b.title}`).join(' '));

Output

1:typo 3:crash

Explanation: the constraint lets the body use id, while T keeps the extra fields like title in the result.

19. Generic Record builderIntermediate

Index a list of objects by one of their keys.

Show solution
function indexBy<T, K extends keyof T>(items: T[], key: K): Record<string, T> {
  const out: Record<string, T> = {};
  for (const item of items) out[String(item[key])] = item;
  return out;
}
const users = [
  { email: 'ana@test.io', role: 'admin' },
  { email: 'ben@test.io', role: 'viewer' },
];
const byEmail = indexBy(users, 'email');
console.log(byEmail['ben@test.io'].role, Object.keys(byEmail).length);

Output

viewer 2

Explanation: a lookup object turns repeated find calls into direct key access.

20. Generic async wrapperIntermediate

Time any async function and return its typed result.

Show solution
async function timed<T>(label: string, fn: () => Promise<T>): Promise<T> {
  const start = Date.now();
  const value = await fn();
  console.log(`${label} done`, Date.now() - start >= 0);
  return value;
}
const count = await timed('load users', async () => 3);
console.log(count + 1);

Output

load users done true
4

Explanation: the wrapper adds behaviour around any async call, and T passes the original result type through.

TypeScript Real-World QA Scenario Programs

21. Utility types are genericsReal-world

Use Partial, Pick and ReturnType together.

Show solution
function makeUser() {
  return { id: 1, name: 'Ana', email: 'ana@test.io', role: 'qa' };
}
type User = ReturnType<typeof makeUser>;
type Patch = Partial<Pick<User, 'name' | 'email'>>;
function patch(u: User, p: Patch): User {
  return { ...u, ...p };
}
const updated = patch(makeUser(), { email: 'ana@new.io' });
console.log(updated.name, updated.email);

Output

Ana ana@new.io

Explanation: built-in utility types are just generic types, and they can be combined to derive new types from existing code.

22. Conditional typeReal-world

Define ElementOf&lt;T&gt; that extracts the item type of an array.

Show solution
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
const browsers = ['chromium', 'firefox'] as const;
type Browser = ElementOf<typeof browsers>;
const b: Browser = 'firefox';
// @ts-expect-error: Type '"safari"' is not assignable to type '"chromium" | "firefox"'
const bad: Browser = 'safari';
console.log(b, bad);

Output

firefox safari

Explanation: infer U captures a type inside a pattern. Here it pulls the element type out of the readonly tuple.

23. Mapped generic typeReal-world

Create Nullable&lt;T&gt; that allows null for every property.

Show solution
type Nullable<T> = { [K in keyof T]: T[K] | null };
interface Profile {
  name: string;
  phone: string;
}
const draft: Nullable<Profile> = { name: 'Ana', phone: null };
console.log(`${draft.name}: ${draft.phone ?? 'no phone yet'}`);

Output

Ana: no phone yet

Explanation: a mapped type loops over the keys of T and transforms each property type.

24. Generic event bus with typed payloadsReal-world

Map event names to payload types so emit checks its argument.

Show solution
type Events = {
  testStart: { name: string };
  testEnd: { name: string; passed: boolean };
};
class Bus<E extends Record<string, object>> {
  private handlers: { [K in keyof E]?: ((p: E[K]) => void)[] } = {};
  on<K extends keyof E>(event: K, fn: (p: E[K]) => void): void {
    (this.handlers[event] ??= []).push(fn);
  }
  emit<K extends keyof E>(event: K, payload: E[K]): void {
    this.handlers[event]?.forEach(fn => fn(payload));
  }
}
const bus = new Bus<Events>();
bus.on('testEnd', p => console.log(`${p.name}: ${p.passed ? 'PASS' : 'FAIL'}`));
bus.emit('testEnd', { name: 'login', passed: true });
// @ts-expect-error: Property 'passed' is missing
bus.emit('testEnd', { name: 'cart' });

Output

login: PASS
cart: FAIL

Explanation: the event name picks the payload type, so a missing field is caught at compile time. At runtime the bad call still runs, which is why a second line prints.

25. Generic retry with typed resultReal-world

Retry any async action and keep its return type.

Show solution
async function retry<T>(fn: (attempt: number) => Promise<T>, times: number): Promise<T> {
  for (let i = 1; ; i++) {
    try {
      return await fn(i);
    } catch (e) {
      if (i >= times) throw e;
    }
  }
}
const status = await retry(async n => {
  if (n < 2) throw new Error('503');
  return { code: 200, attempt: n };
}, 3);
console.log(status.code, status.attempt);

Output

200 2

Explanation: T is inferred from the callback’s return value, so status.code is typed without any annotation.

26. Generic paginate helperReal-world

Split results into pages with a typed page object.

Show solution
interface Page<T> {
  items: T[];
  page: number;
  totalPages: number;
}
function paginate<T>(all: T[], size: number, page: number): Page<T> {
  const totalPages = Math.ceil(all.length / size);
  return { items: all.slice((page - 1) * size, page * size), page, totalPages };
}
const p2 = paginate(['a', 'b', 'c', 'd', 'e'], 2, 2);
console.log(p2.items.join(','), `${p2.page}/${p2.totalPages}`);

Output

c,d 2/3

Explanation: one generic function pages strings, users or test results alike.

27. Generic groupByReal-world

Group objects by the value of a key.

Show solution
function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
  const out = new Map<T[K], T[]>();
  for (const item of items) out.set(item[key], [...(out.get(item[key]) ?? []), item]);
  return out;
}
const runs = [
  { test: 'login', browser: 'chromium' },
  { test: 'cart', browser: 'firefox' },
  { test: 'search', browser: 'chromium' },
];
for (const [browser, list] of groupBy(runs, 'browser')) console.log(`${browser}: ${list.map(r => r.test).join(', ')}`);

Output

chromium: login, search
firefox: cart

Explanation: using a Map keyed by T[K] keeps the key type exact, whether it is a string, number or literal.

28. Generic deep readonlyReal-world

Make every nested property readonly with a recursive generic type.

Show solution
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] };
const config: DeepReadonly<{ use: { baseURL: string } }> = { use: { baseURL: 'https://example.com' } };
// @ts-expect-error: Cannot assign to 'baseURL' because it is a read-only property
const change = () => (config.use.baseURL = 'x');
console.log(config.use.baseURL, typeof change);

Output

https://example.com function

Explanation: the type calls itself for nested objects, so even deep fields become readonly.

29. Generic fixture factoryReal-world

Build test fixtures by merging overrides into typed defaults.

Show solution
function fixture<T extends object>(defaults: T) {
  let seq = 0;
  return (overrides: Partial<T> = {}): T & { seq: number } => ({ ...defaults, ...overrides, seq: ++seq });
}
const makeOrder = fixture({ product: 'pen', qty: 1, paid: false });
const o1 = makeOrder();
const o2 = makeOrder({ qty: 5, paid: true });
console.log(o1.seq, o1.qty, o2.seq, o2.qty, o2.paid);

Output

1 1 2 5 true

Explanation: the factory returns a function whose overrides are checked against the defaults’ shape, and a closure counts each fixture.

30. Typed pipe of functionsReal-world

Compose two transformations with generic types.

Show solution
function pipe<A, B, C>(f: (a: A) => B, g: (b: B) => C): (a: A) => C {
  return a => g(f(a));
}
const toSeconds = (ms: number) => ms / 1000;
const label = (s: number) => `${s.toFixed(1)}s`;
const format = pipe(toSeconds, label);
console.log(format(1840), format(250));

Output

1.8s 0.3s

Explanation: B links the output of the first function to the input of the second, so mismatched steps fail to compile.

Keep Practising

Leave a Comment