TypeScript · lesson 9 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Generics in TypeScript
Generic functions, classes, constraints, defaults, distributive conditional types and infer — each shown with real tsc errors and tsx output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.
Why Generics?
A function that works for any type has two options: any, which forgets the type, or a type parameter, which carries it through.
function wrapAny(value: any): { value: any } {
return { value };
}
function wrap<T>(value: T): { value: T } {
return { value };
}
const a = wrapAny(42);
const b = wrap(42);
console.log(a.value.toUpperCase());
console.log(b.value.toUpperCase());
$ npx tsc --noEmit --module nodenext why.ts
why.ts(13,21): error TS2339: Property 'toUpperCase' does not exist on type 'number'.
Only the generic version catches the bug. wrap(42) inferred T as number from the argument, so the return type is { value: number } — and it’s only an error because the type survived the trip through the function.
Type Argument Inference
You rarely write the type argument; TypeScript infers it from the call. Emitting declarations shows what it chose:
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
export const p1 = pair("hello", 42);
export const p2 = pair(true, ["a"]);
export const p3 = pair<string, number | null>("x", null);
export const empty = pair([], {});
$ npx tsc --declaration --emitDeclarationOnly --outDir types infer-args.ts
$ cat types/infer-args.d.ts
export declare const p1: [string, number];
export declare const p2: [boolean, string[]];
export declare const p3: [string, number | null];
export declare const empty: [never[], {}];
Each call infers its own A and B. p3 shows the explicit form: without <string, number | null> the second element would be typed null alone. And pair([], {}) infers never[] — an array nothing can be added to — which is the classic case where you need an explicit type argument or an annotation.
Constraints
T extends X limits T to types assignable to X, which in turn lets the function use X’s members.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
console.log(longest("apple", "fig"));
console.log(longest([1, 2], [1, 2, 3]));
console.log(longest(10, 20));
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const users = [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }];
const ages: number[] = pluck(users, "age");
pluck(users, "email");
$ npx tsc --noEmit --module nodenext constraints.ts
constraints.ts(7,21): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
constraints.ts(15,14): error TS2345: Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.
Numbers have no length, and "email" isn’t a key of the user objects. Remove both bad lines, add console.log(ages), and run it:
$ npx tsx constraints.ts
apple
[ 1, 2, 3 ]
[ 30, 25 ]
longest returns the same type it was given — a string for strings, a number[] for arrays — which a parameter typed { length: number } couldn’t do: it would hand back { length: number } and lose everything else.
Generic Interfaces and Classes
A generic class fixes its type parameter when an instance is created, and every method uses it.
interface Stack<T> {
push(item: T): void;
pop(): T | undefined;
readonly size: number;
}
class ArrayStack<T> implements Stack<T> {
#items: T[] = [];
push(item: T) { this.#items.push(item); }
pop() { return this.#items.pop(); }
get size() { return this.#items.length; }
}
const numbers = new ArrayStack<number>();
numbers.push(1);
numbers.push(2);
console.log(numbers.pop(), numbers.size);
const inferred = new ArrayStack();
inferred.push("anything");
inferred.push(42);
console.log(inferred.size);
numbers.push("3");
$ npx tsc --noEmit --module nodenext repository.ts
repository.ts(24,14): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
numbers is an ArrayStack<number>, so push("3") is rejected. But new ArrayStack() with no type argument and nothing to infer from gets T = unknown — it silently accepts anything. Give generic classes an explicit type argument when the constructor doesn’t mention T.
Default Type Parameters
<T = X> supplies a type when the caller doesn’t give one.
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
const raw: ApiResponse = { data: { name: "Alice" }, status: 200 };
const typed: ApiResponse<{ name: string }> = { data: { name: "Alice" }, status: 200 };
console.log(typed.data.name.toUpperCase());
console.log(raw.data.name);
$ npx tsc --noEmit --module nodenext defaults.ts
defaults.ts(10,13): error TS18046: 'raw.data' is of type 'unknown'.
Defaulting to unknown rather than any means an untyped response is safe by default: you can pass raw.data around, but you have to narrow it before reading name.
Conditional Types and Distribution
A conditional type applied to a bare type parameter distributes over a union — it runs once per member.
type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayOnce<T> = [T] extends [unknown] ? T[] : never;
const a: ToArray<string | number> = [1, "two"];
const b: ToArrayOnce<string | number> = [1, "two"];
type NonNull<T> = T extends null | undefined ? never : T;
const c: NonNull<string | null | undefined> = null;
$ npx tsc --noEmit --module nodenext distribute.ts
distribute.ts(4,7): error TS2322: Type '(string | number)[]' is not assignable to type 'string[] | number[]'.
Type '(string | number)[]' is not assignable to type 'string[]'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.
distribute.ts(8,7): error TS2322: Type 'null' is not assignable to type 'string'.
ToArray<string | number> became string[] | number[], so a mixed array fails. Wrapping both sides in a one-element tuple turns distribution off, giving (string | number)[], which is why b is fine. Distribution is also how filtering works: NonNull maps null and undefined to never, and never disappears from a union, leaving string. That’s exactly the built-in NonNullable<T>.
The infer Keyword
Inside a conditional type, infer X captures part of the matched type.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type ElementType<T> = T extends readonly (infer E)[] ? E : never;
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;
function parse(input: string) {
return { ok: true, length: input.length };
}
const r: MyReturnType<typeof parse> = { ok: true, length: "5" };
const e: ElementType<string[][]> = "x";
const u: Unwrap<Promise<Promise<number>>> = "x";
$ npx tsc --noEmit --module nodenext infer.ts
infer.ts(9,51): error TS2322: Type 'string' is not assignable to type 'number'.
infer.ts(10,7): error TS2322: Type 'string' is not assignable to type 'string[]'.
infer.ts(11,7): error TS2322: Type 'string' is not assignable to type 'number'.
MyReturnType<typeof parse> read the return type straight off the function — note typeof, which turns a value into its type. ElementType<string[][]> peels one level, giving string[]. Unwrap recurses until the type isn’t a promise. The standard library ships these as ReturnType, and Awaited.
A Worked Example: A Typed Event Emitter Class
A class generic over an event map links each event name to its payload type.
class TypedEmitter<Events extends Record<string, unknown>> {
#listeners: { [K in keyof Events]?: Set<(data: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
(this.#listeners[event] ??= new Set()).add(listener);
return this;
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.#listeners[event]?.forEach((listener) => listener(data));
}
}
interface AppEvents {
userLoggedIn: { userId: string };
orderPlaced: { orderId: string; total: number };
}
const emitter = new TypedEmitter<AppEvents>();
$ npx tsc --noEmit --module nodenext emitter.ts
emitter.ts(19,34): error TS2344: Type 'AppEvents' does not satisfy the constraint 'Record<string, unknown>'.
Index signature for type 'string' is missing in type 'AppEvents'.
A surprise: AppEvents has only string keys, yet it doesn’t satisfy Record<string, unknown>. That constraint has an index signature, and an interface is never assumed to have one, because declaration merging could add incompatible members later. Declaring the map with type fixes it:
class TypedEmitter<Events extends Record<string, unknown>> {
#listeners: { [K in keyof Events]?: Set<(data: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
(this.#listeners[event] ??= new Set()).add(listener);
return this;
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.#listeners[event]?.forEach((listener) => listener(data));
}
}
type AppEvents = {
userLoggedIn: { userId: string };
orderPlaced: { orderId: string; total: number };
};
const emitter = new TypedEmitter<AppEvents>()
.on("userLoggedIn", ({ userId }) => console.log(`user ${userId} logged in`))
.on("orderPlaced", (o) => console.log(`order ${o.orderId}: ${o.total.toFixed(2)}`));
emitter.emit("userLoggedIn", { userId: "123" });
emitter.emit("orderPlaced", { orderId: "A1", total: 19.5 });
emitter.emit("orderPlaced", { orderId: "A2" });
emitter.on("unknownEvent", () => {});
$ npx tsc --noEmit --module nodenext emitter2.ts
emitter2.ts(25,29): error TS2345: Argument of type '{ orderId: string; }' is not assignable to parameter of type '{ orderId: string; total: number; }'.
Property 'total' is missing in type '{ orderId: string; }' but required in type '{ orderId: string; total: number; }'.
emitter2.ts(26,12): error TS2345: Argument of type '"unknownEvent"' is not assignable to parameter of type 'keyof AppEvents'.
Returning this makes .on chainable while keeping the full TypedEmitter<AppEvents> type. Remove the two bad calls:
$ npx tsx emitter2.ts
user 123 logged in
order A1: 19.50
Practice
1. Write groupBy<T, K extends PropertyKey>(items: T[], key: (item: T) => K): Record<K, T[]> and group some words by their first letter.
Solution
function groupBy<T, K extends PropertyKey>(items: T[], key: (item: T) => K): Record<K, T[]> {
const out = {} as Record<K, T[]>;
for (const item of items) {
(out[key(item)] ??= []).push(item);
}
return out;
}
const byLetter = groupBy(["apple", "avocado", "banana"], (w) => w[0] ?? "");
const byLength = groupBy([1, 22, 333, 44], (n) => (String(n).length > 1 ? "long" : "short"));
console.log(byLetter, byLength.long);
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
{ a: [ 'apple', 'avocado' ], b: [ 'banana' ] } [ 22, 333, 44 ]
PropertyKey is string | number | symbol — anything that can be an object key. In the second call K is inferred as "long" | "short", so byLength.long type-checks and byLength.medium would not. The as is needed because {} doesn’t yet have the keys the return type promises.
2. Give ArrayStack a default so new ArrayStack() without a type argument accepts only strings.
Solution
class ArrayStack<T = string> {
#items: T[] = [];
push(item: T) { this.#items.push(item); }
get size() { return this.#items.length; }
}
const s = new ArrayStack();
s.push("ok");
console.log(s.size);
s.push(42);
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(10,8): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
With nothing to infer from, the default applies instead of unknown.
3. Write type FirstArg<F> that extracts the type of a function’s first parameter, and check it against (id: number, name: string) => void.
Solution
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
type Handler = (id: number, name: string) => void;
const ok: FirstArg<Handler> = 42;
const bad: FirstArg<Handler> = "42";
const none: FirstArg<() => void> = 1;
console.log(ok, bad, none);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(5,7): error TS2322: Type 'string' is not assignable to type 'number'.
FirstArg<Handler> is number, so only bad is rejected. For a function with no parameters, infer A falls back to unknown, so none accepts anything. If that matters, return never for zero-parameter functions by matching F extends () => any first.