TypeScript From Zero
Functions and Generics
Function types, overloads, this parameters, generics and constraints, a typed fetch wrapper and a typed event emitter.
On this page
In one line: a function type says what goes in and what comes out. A generic is a function (or type) with a placeholder, written <T>, that TypeScript fills in from the arguments, so one definition keeps the exact types for every use. Learn to read a signature, to add a constraint (T extends ...), and to know when you do not need a generic at all.
Lesson 2 described objects. This lesson is about functions, which is where most real TypeScript lives: parameters, return types, overloads, the this problem, and then generics, which let one function work on many types without giving up checking. We end with two small useful tools, a typed fetch wrapper and a typed event emitter. Start at Lesson 0 if you have no project yet. The interview versions are in Full-Stack Q&A, Lesson 3 and Frontend Depth, Lesson 4.
What I ran / checked. Everything here was compiled with TypeScript 7.0.2 (tsc --pretty false) on Node v22.22.0, using the tsconfig from Lesson 0. Programs that print something were run with node file.ts, and the output is quoted. The fetch wrapper ran against a small HTTP server on 127.0.0.1 that I started in the same script, so no real website was involved, and the port number in the one message that shows it changes on every run. Error messages are copied from the compiler. The <const T> example needs a recent TypeScript; I only know it works on 7.0.2. I did not read the handbook for this lesson. The "reveal the type" trick (const x: never = value) is the one from Lesson 1.
Words used in this lesson
- Signature is the part of a function that is its type: the parameters and the return type, without the body.
- Optional parameter (
x?: string) may be left out when calling. Default parameter (x = "!") gets a value if left out. Rest parameter (...nums: number[]) collects the extra arguments into an array. voidis the return type of a function whose result you do not use.neveris the type of a function that never returns normally.- Overload is a function with several allowed call shapes, listed before one shared implementation.
thisparameter is a fake first parameter, namedthis, that only tells the checker whatthismust be. It is erased.- Generic is a function, type or class with one or more type parameters (placeholders such as
T). - Type argument is the real type that fills the placeholder: in
Box<number>,numberis the type argument. - Constraint (
T extends X) limits which types may fill the placeholder. - Inference is TypeScript choosing the type arguments for you from the call.
keyof Tis the union of the property names ofT.typeof x(in a type) is the type of the valuex.T[K](indexed access) is the type of propertyKonT.
Function types
You can write the type of a function on its own, with an arrow:
type BinaryOp = (a: number, b: number) => number;const add: BinaryOp = (a, b) => a + b;const bad: BinaryOp = (a, b) => String(a + b);const fewer: BinaryOp = (a) => a;const more: BinaryOp = (a, b, c) => a + b + c;src/g1.ts(3,33): error TS2322: Type 'string' is not assignable to type 'number'.src/g1.ts(5,7): error TS2322: Type '(a: any, b: any, c: any) => any' is not assignable to type 'BinaryOp'. Target signature provides too few arguments. Expected 3 or more, but got 2.src/g1.ts(5,25): error TS7006: Parameter 'a' implicitly has an 'any' type.src/g1.ts(5,28): error TS7006: Parameter 'b' implicitly has an 'any' type.src/g1.ts(5,31): error TS7006: Parameter 'c' implicitly has an 'any' type.Notice four things:
- In
add, I wrote no types onaandb. They come fromBinaryOp. This is called contextual typing: the place where a function is used tells TypeScript the parameter types. It is why[1, 2].map((n) => n * 2)needs no note onn. badreturned text where a number was required.feweris fine: a function that takes fewer parameters can be used where more are passed, because JavaScript ignores extra arguments. That is what makes callbacks likearr.map((x) => x)work, withmappassing three arguments.moreasks for more than the type gives, so it is an error. Alsoa,b,cbecameany, because there was no type forcto come from (theTS7006lines, same as in Lesson 0).
Parameters: optional, default and rest
function greet(name: string, greeting?: string, punct = "!") { return (greeting ?? "Hello") + ", " + name + punct;}console.log(greet("Ada"), greet("Bo", "Hi"), greet("Cy", undefined, "?"));greet();greet("a", "b", "c", "d");function sum(label: string, ...nums: number[]) { return label + nums.reduce((a, b) => a + b, 0);}console.log(sum("t=", 1, 2, 3));sum("t=", 1, "2");const args: [string, number, number] = ["x", 1, 2];console.log(sum(...args));Hello, Ada! Hi, Bo! Hello, Cy?t=6x3src/g1.ts(11,1): error TS2554: Expected 1-3 arguments, but got 0.src/g1.ts(12,22): error TS2554: Expected 1-3 arguments, but got 4.src/g1.ts(18,14): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.(The three lines of program output came from running a copy of the file without the failing lines; the tsc lines came from the file above.)
greeting?: stringhas the typestring | undefinedinside the function.punct = "!"has the typestring; the default is the type. A default parameter is also optional for the caller, so the message says "1-3 arguments".- A required parameter cannot follow an optional one. Put optional and default parameters last.
...nums: number[]collects the rest. A call with a wrong type in the rest is checked item by item.- A tuple type lets you spread an array into fixed positions:
sum(...args)works becauseargshas exactly the right shape (Lesson 1).
void and never
function fail(msg: string): never { throw new Error(msg); }function log(msg: string): void { console.log(msg); }const cb: () => void = () => 42;const val = cb();const probe: string = val;src/g1.ts(27,7): error TS2322: Type 'void' is not assignable to type 'string'.void means "do not use the result". A function type that returns void accepts a function that returns something (() => 42 was accepted), because the caller is promised not to look at the result. This is why arr.forEach((x) => list.push(x)) is fine even though push returns a number. never is for a function that always throws or never ends.
Overloads
Sometimes the result type depends on the argument type in a way a union cannot show. You write overload signatures (just the shape, no body), then one implementation that accepts everything:
function make(tag: "a"): string;function make(tag: "b"): number;function make(tag: "a" | "b"): string | number { return tag === "a" ? "text" : 1;}const x = make("a");const y = make("b");const px: never = x;const py: never = y;src/g2.ts(17,7): error TS2322: Type 'string' is not assignable to type 'never'.src/g2.ts(18,7): error TS2322: Type 'number' is not assignable to type 'never'.make("a") is a string and make("b") is a number; a single union return type could not say that. The implementation signature is not visible to callers. Only the overloads count:
function len(x: string): number;function len(x: unknown[]): number;function len(x: string | unknown[]): number { return x.length;}console.log(len("abc"), len([1, 2]));len(42);len(Math.random() > 0.5 ? "a" : [1]);3 2src/g2.ts(7,5): error TS2769: No overload matches this call. The last overload gave the following error. Argument of type 'number' is not assignable to parameter of type 'unknown[]'.src/g2.ts(8,5): error TS2769: No overload matches this call. The last overload gave the following error. Argument of type '"a" | number[]' is not assignable to parameter of type 'unknown[]'. Type 'string' is not assignable to type 'unknown[]'.The second error is the useful lesson. The argument was string | number[], and the implementation accepts that, but neither overload does, so the call fails. Overloads are matched one at a time and each must accept the whole argument.
Prefer a union or a generic when you can. function len(x: string | unknown[]): number has no overloads and accepts all of these calls. Use overloads only when the return type really depends on the argument. A union parameter plus a generic often does the same job with less code.
this in functions
In JavaScript, this depends on how a function is called, not where it was written. TypeScript lets you state what a function expects, with a fake first parameter named this, which is removed from the output:
type Handler = { label: string; run(this: { label: string }): string };const h: Handler = { label: "hi", run() { return this.label; } };console.log(h.run());const detached = h.run;detached();function who(this: { name: string }) { return this.name; }who();console.log(who.call({ name: "Ada" }));src/g3.ts(5,1): error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type '{ label: string; }'.src/g3.ts(8,1): error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type '{ name: string; }'.Both bad calls (detached() and who()) are caught at compile time. Without the this parameter, tsc printed nothing for the same detached call in a class method (Lesson 2: it failed at runtime with TypeError: Cannot read properties of undefined (reading 'label')). Running the first file without the failing lines, h.run() printed hi. Arrow functions have no this of their own; they use the one around them. That is why an arrow-function field in a class is safe to pass around.
Generics: one function, many types
Write a function that returns the first item of a list. Without generics, you must choose: any[] (no checking) or one function per type.
function first<T>(items: T[]): T | undefined { return items[0];}const a = first([1, 2, 3]);const b = first(["x"]);const c = first<boolean>([]);I revealed the types of a, b and c with never:
src/h1.ts(7,7): error TS2322: Type 'number | undefined' is not assignable to type 'never'. Type 'undefined' is not assignable to type 'never'.src/h1.ts(8,7): error TS2322: Type 'string | undefined' is not assignable to type 'never'. Type 'undefined' is not assignable to type 'never'.src/h1.ts(9,7): error TS2322: Type 'boolean | undefined' is not assignable to type 'never'. Type 'undefined' is not assignable to type 'never'.<T> is a variable for a type. For first([1, 2, 3]), TypeScript looked at the argument (number[]) and decided T = number. This is inference, and most of the time you do not write the type argument yourself. In first<boolean>([]) I did write it, because an empty list gives nothing to infer from.
Read function first<T>(items: T[]): T | undefined aloud: "for any type T, take a list of T and give back a T or undefined". The link between input and output is the point. The type does not get lost on the way through.
Inference follows the arguments
function identity<T>(x: T): T { return x; }const i1 = identity("hi");let i2 = identity("hi");src/h1.ts(14,7): error TS2322: Type '"hi"' is not assignable to type 'never'.src/h1.ts(15,7): error TS2322: Type 'string' is not assignable to type 'never'.i1 is the literal type "hi" (a const), and i2 is widened to string (a let), the same rule as Lesson 1.
Inference also works from a callback. TypeScript fills T from the first argument and U from what the callback returns:
function mapIt<T, U>(xs: T[], f: (x: T) => U): U[] { const out: U[] = []; for (const x of xs) out.push(f(x)); return out;}const lens = mapIt(["a", "bb"], (s) => s.length);mapIt([1, 2], (n) => n.toUpperCase());src/o1.ts(7,7): error TS2322: Type 'number[]' is not assignable to type 'never'.src/o1.ts(8,24): error TS2339: Property 'toUpperCase' does not exist on type 'number'.s was a string with no note from me, lens is number[], and in the second call n is known to be a number. (The first error line is my reveal of lens.)
When one T is used for two parameters, the first one wins, and the second must fit:
function same<T>(a: T, b: T): T { return a; }same(1, "a");same<number>(1, "a");src/o1.ts(12,9): error TS2345: Argument of type '"a"' is not assignable to parameter of type '1'.src/o1.ts(13,17): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.In the first call TypeScript took T from 1 (so the message says 1), and "a" does not fit. If you want it to accept both, you need two type parameters or a union.
Constraints: T extends ...
A bare T can be anything, so you can do almost nothing with it:
function wrong<T>(x: T) { return x.toUpperCase(); }src/h1.ts(36,36): error TS2339: Property 'toUpperCase' does not exist on type 'T'.Say what you need from T with extends:
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;}console.log(longest("abc", "de"), longest([1, 2], [1]));longest(10, 20);abc [ 1, 2 ]src/h1.ts(21,9): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.Strings and arrays both have a length, so longest works for both and returns the same type that went in (a string for strings, a number[] for arrays). A function that took { length: number } and returned { length: number } would lose that. Numbers have no length, so longest(10, 20) is refused.
A generic must earn its place. If T appears only once in a signature, it links nothing. function log<T>(x: T): void is the same as function log(x: unknown): void. A good question to ask: "does T connect an input to an output, or one input to another?" If not, remove it.
Default type parameters
A type parameter can have a default, like a default argument:
interface Result<T, E = Error> { ok: boolean; data?: T; error?: E }const r1: Result<string> = { ok: true, data: "x" };const r2: Result<string, string> = { ok: false, error: new Error("no") };src/i1.ts(6,49): error TS2322: Type 'Error' is not assignable to type 'string'.Result<string> means Result<string, Error>. In r2 I gave E as string, so an Error object no longer fits. A default makes a generic easy to use in the common case. You need to provide the type arguments if there is no default and nothing to infer from: const b2: Box = { value: 1 } fails with TS2314: Generic type 'Box<T>' requires 1 type argument(s).
Generic interfaces, types and classes
Everything above works for types as well:
type Stack<T> = { items: T[]; push(x: T): void; pop(): T | undefined };function makeStack<T>(): Stack<T> { const items: T[] = []; return { items, push: (x) => { items.push(x); }, pop: () => items.pop() };}const s = makeStack<number>();s.push(1);s.push("2");src/i1.ts(15,8): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.Built-in types are generic too: Array<T>, Map<K, V>, Promise<T>. I checked that Promise.resolve(5) has the type Promise<number>, and that new Map<string, number>().set("a", "b") fails on the "b".
A class can have type parameters, with a constraint and a default:
class Cache<K extends string | number, V = unknown> { private data = new Map<K, V>(); get(k: K): V | undefined { return this.data.get(k); } set(k: K, v: V): this { this.data.set(k, v); return this; }}const c = new Cache<string, number>().set("a", 1);new Cache<boolean>();src/i1.ts(31,11): error TS2344: Type 'boolean' does not satisfy the constraint 'string | number'.A const type parameter
A recent TypeScript feature: <const T> asks inference to keep literal types, as if the argument had as const (Lesson 2).
function ident<const T>(x: T): T { return x; }const k1 = ident(["a", "b"]);const k2 = ident({ mode: "dark" });src/o1.ts(27,7): error TS2322: Type 'readonly ["a", "b"]' is not assignable to type 'never'.src/o1.ts(28,7): error TS2322: Type '{ readonly mode: "dark"; }' is not assignable to type 'never'.Without const, k1 would be string[]. I use this when a function takes a config or a list of names and the exact words matter.
keyof, typeof and indexed access
Three small operators are the base for most advanced types (Lesson 4):
const user = { id: 1, name: "Ada", tags: ["a", "b"] };type User = typeof user;type Keys = keyof User;type NameType = User["name"];type TagItem = User["tags"][number];const k: Keys = "email";type Roles = { admin: "A"; guest: "G" };type R = Roles[keyof Roles];const r: R = "X";type Arr = string[];type El = Arr[number];const e: El = 5;src/j1.ts(7,7): error TS2322: Type '"email"' is not assignable to type '"id" | "name" | "tags"'.src/j1.ts(12,7): error TS2322: Type '"X"' is not assignable to type 'R'.src/j1.ts(28,7): error TS2322: Type 'number' is not assignable to type 'string'.typeof user(in a type position) takes the type from a value.Useris{ id: number; name: string; tags: string[] }and you did not write it twice.keyof Useris"id" | "name" | "tags", as the first error shows.User["name"]isstring.User["tags"][number]isstring(the type of an item in the list).Roles[keyof Roles]is the union of all the values,"A" | "G".Arr[number]is the item type of an array, ande: El = 5fails withstringas the target.
They combine into the most useful generic pattern: a key that must belong to the object:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}const nm = getProp(user, "name");getProp(user, "age");src/h1.ts(30,15): error TS2345: Argument of type '"age"' is not assignable to parameter of type '"id" | "name"'.(In that file user had only id and name.) The result nm has the type string: K was inferred as "name", and T[K] is T["name"]. A typo in the key is a compile error, and the result type is exact. I also ran a pick(user, "id", "name") that returns Pick<T, K>; it printed { id: 1, name: 'Ada' }. Pick and the other built-in helpers are the subject of Lesson 4.
A typed fetch wrapper
fetch(...).then((r) => r.json()) gives any. The tempting wrapper puts a generic around it:
async function getJson<T>(url: string): Promise<T> { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); return (await res.json()) as T;}This compiles and is wrong. T is chosen by the caller and never checked. It is the same lie as the as in Lesson 0: the generic only moves the cast into the function. A better design makes the caller supply a check, a parser that turns unknown into T:
type Parser<T> = (raw: unknown) => T;async function fetchJson<T>(url: string, parse: Parser<T>): Promise<T> { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); const raw: unknown = await res.json(); return parse(raw);}type Todo = { id: number; title: string; done: boolean };function parseTodo(raw: unknown): Todo { if (typeof raw !== "object" || raw === null) throw new Error("not an object"); const o = raw as Record<string, unknown>; if (typeof o.id !== "number") throw new Error("id must be a number"); if (typeof o.title !== "string") throw new Error("title must be a string"); if (typeof o.done !== "boolean") throw new Error("done must be a boolean"); return { id: o.id, title: o.title, done: o.done };}Now T is inferred from the parser's return type, and the data is checked before anyone treats it as a Todo. The cast inside parseTodo (raw as Record<string, unknown>) is safe because I checked typeof raw === "object" and not null first, and every field is checked after. A third step: return errors as values and not as exceptions, using the discriminated union from Lesson 1:
type Ok<T> = { ok: true; value: T };type Err = { ok: false; error: string };async function tryFetch<T>(url: string, parse: Parser<T>): Promise<Ok<T> | Err> { try { return { ok: true, value: await fetchJson(url, parse) }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }}I ran all three against a local server that returns a good Todo at /todo/1, a bad one (id is the text "1", no done) at /todo/bad, and a 404 for anything else:
getJson says id is a number, runtime says: stringfetchJson: WRITE false/todo/1 ok write/todo/bad error: id must be a number/missing error: HTTP 404 for http://127.0.0.1:42019/missingThe first line is the problem with getJson<Todo>: the type said number and the data said string, and tsc printed no error. With tryFetch, the caller has to check r.ok before touching r.value, because the union is narrowed on ok.
Writing a parser by hand for every type is long. In Lesson 6 we use a library (Zod) that builds the parser from a schema and gives you the type from the same schema.
A typed event emitter
An event emitter lets code say "when X happens, call me". The common bug is that the event name and the data do not match. We can make the compiler check both, with one map from event name to payload type:
type Handler<P> = (payload: P) => void;class Emitter<Events extends Record<string, unknown>> { private handlers: { [K in keyof Events]?: Handler<Events[K]>[] } = {}; on<K extends keyof Events>(event: K, fn: Handler<Events[K]>): () => void { const list = (this.handlers[event] ??= []); list.push(fn); return () => { const i = list.indexOf(fn); if (i >= 0) list.splice(i, 1); }; } emit<K extends keyof Events>(event: K, payload: Events[K]): void { for (const fn of this.handlers[event] ?? []) fn(payload); }}The pieces are the ones from this lesson: K extends keyof Events makes the name one of the known events, and Events[K] is the payload type for that name. The { [K in keyof Events]?: ... } line is a mapped type (Lesson 4); here it just says "for each event name, an optional list of handlers". on returns a function that removes the handler. Use it:
type AppEvents = { login: { user: string }; logout: undefined; error: Error;};const bus = new Emitter<AppEvents>();const off = bus.on("login", (p) => console.log("hello", p.user.toUpperCase()));bus.on("error", (e) => console.log("error:", e.message));bus.on("logout", () => console.log("bye"));bus.emit("login", { user: "ada" });bus.emit("error", new Error("disk full"));bus.emit("logout", undefined);off();bus.emit("login", { user: "nobody sees this" });console.log("done");hello ADAerror: disk fullbyedonep has the type { user: string } without a note from me, and after off() the last emit did nothing. Now the mistakes. I used a declaration of the class here to keep the file short (declare class has no body), and the checks are the same:
bus.emit("logn", { user: "a" });bus.emit("login", { usr: "a" });bus.emit("login", "ada");bus.on("error", (e) => e.user);bus.on("login", (p: number) => {});src/l3.ts(8,10): error TS2345: Argument of type '"logn"' is not assignable to parameter of type 'keyof AppEvents'.src/l3.ts(9,21): error TS2561: Object literal may only specify known properties, but 'usr' does not exist in type '{ user: string; }'. Did you mean to write 'user'?src/l3.ts(10,19): error TS2345: Argument of type 'string' is not assignable to parameter of type '{ user: string; }'.src/l3.ts(11,26): error TS2339: Property 'user' does not exist on type 'Error'.src/l3.ts(12,17): error TS2345: Argument of type '(p: number) => void' is not assignable to parameter of type 'Handler<{ user: string; }>'. Types of parameters 'p' and 'payload' are incompatible. Type '{ user: string; }' is not assignable to type 'number'.A wrong event name, a typo in the payload (with the "did you mean" hint), the wrong payload type, a wrong use inside a handler and a handler with the wrong parameter type: all five are caught. One rough edge: an event with no data is typed undefined and you must still pass undefined to emit. A nicer API can be built with tuples of parameters; I did not try it here.
Try it
- Write
function last<T>(xs: T[]): T | undefined. Call it with[1, 2]and with[]. What is the type of the result in each case? - Write
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][]. Call it on a list of users with"name", then with"nmae". What happens? - Write
function merge<T extends object, U extends object>(a: T, b: U): T & Uusing{ ...a, ...b }. What is the result type formerge({ a: 1 }, { b: "x" })? - Write
function describe(x: string): stringandfunction describe(x: number): numberas overloads. Then passstring | number. What does the compiler say, and how would you fix it without a third overload? - Take
getJson<T>above and rewrite it so that the caller must pass a parser. Run it against a bad response. What changes at runtime?
Answers.
- For
[1, 2]the result isnumber | undefined. For[]it isundefined(I revealed both with thenevertrick:Type 'undefined' is not assignable to type 'never'), because an empty list gives nothing to inferTfrom. - The first call returns
string[]. The second fails withTS2345: Argument of type '"nmae"' is not assignable to parameter of type 'keyof User'. - The result type is
{ a: number; } & { b: string; }, and at runtimemergeprinted{ a: 1, b: 'x' }. - The call fails with
TS2769: No overload matches this call(the last overload saysArgument of type 'string | number' is not assignable to parameter of type 'number'). The fix is one signature:function describe<T extends string | number>(x: T): T; I randescribe2(5)anddescribe2("a")and got5 a. fetchJson(url, parseTodo)throwsid must be a numberfor the bad response, and the program stops at the real place of the problem, instead of crashing later in unrelated code.
Interview questions
What is a generic and why do we use it? (Must know)
A generic is a function, type or class with a type parameter, a placeholder like T. It lets one definition work for many types and keeps the link between them: first<T>(items: T[]): T | undefined returns a number for a list of numbers and a string for a list of strings. The alternative is any, which loses that link, or a copy of the function for every type.
How does TypeScript infer type arguments? (Must know)
It looks at the arguments of the call and finds a type for each type parameter. For first([1, 2, 3]) it sees number[] and sets T = number. It also infers from callbacks: in mapIt(["a", "bb"], (s) => s.length), T came from the list and U from what the callback returned. When there is nothing to infer from, such as an empty list, I give the type argument myself: first<boolean>([]). If one T is used in two parameters, the first argument sets it, and the second must fit (same(1, "a") failed with parameter of type '1').
What is a constraint and when do you need one? (Must know)
A constraint, T extends X, limits what T can be, so I can use X's members inside the function. A bare T has no members: x.toUpperCase() gave TS2339 ... does not exist on type 'T'. With T extends { length: number }, strings and arrays are accepted, numbers are refused, and the function returns the same type it was given.
What does K extends keyof T give you? (Senior signal)
It says "K must be one of the property names of T". With the return type T[K], getProp(user, "name") returns string, and a wrong key such as "age" is a compile error that lists the allowed keys. It is the pattern behind safe property access, pick, and my typed event emitter (on<K extends keyof Events>).
When is a generic a bad idea? (Senior signal)
When the type parameter appears only once in the signature, so it connects nothing: log<T>(x: T) is just log(x: unknown). A generic that returns a constraint's type loses the caller's type; a generic that links input to output keeps it. I ask: "does T link two places?" If not, I remove it. Extra type parameters make error messages longer too.
Why is getJson<T>(url): Promise<T> a trap? (Must know)
T is chosen by the caller and nothing checks it, so it is a cast hidden inside a function. In my test, getJson<Todo> returned data whose id was a string while the type said number, and there was no compile error. I make the caller pass a parser, (raw: unknown) => T, so that T is inferred from it and the data is checked before it is typed. In real projects I would generate the parser from a schema with a library like Zod (Lesson 6).
How do overloads work, and what is hidden? (Senior signal)
I write several signatures with no body, then one implementation that accepts all of them. The caller only sees the overloads, never the implementation signature. They are tried in order and each must accept the whole argument. In my test len(Math.random() > 0.5 ? "a" : [1]) failed with TS2769: No overload matches this call, although the implementation accepted string | unknown[]. If a union or a generic does the same job, I prefer that.
What is the this parameter? (Senior signal)
A fake first parameter named this that tells the checker what this must be when the function is called. It is erased in the output. function who(this: { name: string }) refused who() with TS2684, and who.call({ name: "Ada" }) worked. It catches the "method lost its object" bug, for example const f = obj.method; f(). Arrow functions take this from where they are written, so they do not need it.
What is the difference between void and never? (Must know)
void is "returns, but ignore the result". A function type returning void can be given a function that returns something (const cb: () => void = () => 42 compiled), which is why forEach callbacks can return values. never is "never returns": it throws, loops forever, or is a value that cannot exist. const val = cb() has the type void, and assigning it to a string failed.
Explain keyof, typeof and indexed access with one example. (Must know)
For const user = { id: 1, name: "Ada", tags: ["a", "b"] }: typeof user is the object's type; keyof typeof user is "id" | "name" | "tags"; (typeof user)["name"] is string; and (typeof user)["tags"][number] is string, the item type. I use typeof so I do not write a type twice, and keyof to limit a key to real property names.
How would you type an event emitter so that event names and payloads must match? (Senior signal)
I keep one map type, type AppEvents = { login: { user: string }; error: Error }, and make the class generic over it. on<K extends keyof Events>(event: K, fn: (payload: Events[K]) => void) and emit<K extends keyof Events>(event: K, payload: Events[K]) tie the name to the payload. In my run, a wrong name, a misspelt payload field, a wrong payload type and a handler with the wrong parameter all failed at compile time, and the handler p was typed with no note from me.
What does a default type parameter do? (Must know)
It is used when the caller does not give that type argument and nothing can be inferred, like a default function argument. interface Result<T, E = Error> means Result<string> is Result<string, Error>. Without a default, Box with no argument gave TS2314: Generic type 'Box<T>' requires 1 type argument(s). I add defaults to make the common case short.
Key takeaways
- A function type is
(params) => result. Parameter types come from context when a callback is typed by its place. - Optional and default parameters go last; rest parameters collect extra arguments.
voidmeans ignore the result;nevermeans it never returns. - Overloads show only their signatures to callers; each must accept the whole argument. A union or generic is often simpler.
- A
thisparameter makes the checker catch lost-thiscalls, and it is erased. - A generic
<T>links types between input and output. TypeScript infers it from the arguments and callbacks. - Add
extendswhen you need members ofT; useK extends keyof TwithT[K]for safe keys; remove aTthat connects nothing. - A generic
fetchwrapper that only casts is a lie. Make the caller pass a parser so thatTis checked. - One map from event name to payload makes an event emitter safe to use.
What's next
Lesson 4: Utility, Mapped and Conditional Types. We use the built-in helpers (Partial, Pick, Omit, Record, ReturnType), then build our own with mapped and conditional types, which is what the { [K in keyof Events]?: ... } line in the emitter was a first look at. Practise first: change Emitter so that emit for an event with undefined payload needs only one argument. Later lessons: Lesson 5 on modules and tsconfig, Lesson 6 on React, Node and Zod, and Lesson 7 on reading errors and migrating to strict mode.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Advanced TypeScript for Interviews: Generics, Narrowing, Utility and Conditional Types · Senior Frontend Depth
- Validation at the Edge: Schemas, DTOs and Trusting Nothing · Backend in Depth
- TypeScript Interview Questions · Full-Stack Questions
Part of the study guide
Related series