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.
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.
- Beginner programs (1–10)
- Intermediate programs (11–20)
- Real-world QA scenario programs (21–30)
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<T>(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<T> 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<number> and Box<string> are two concrete versions.
7. Generic type aliasBeginner
Define Maybe<T> 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<K, V>(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<T> 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<T, U> 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<K, V>.
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<T> 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<T> 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.