TypeScript NotesRohit’s interview study guide
Chapter 06

Functions

Typing parameters and returns, overloads and when generics beat them, async functions and this.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Typing parameters and return types

Added

A function has two edges where types matter: what comes in (parameters) and what goes out (the return value). TypeScript treats them very differently. Parameters are where inference has nothing to go on — the compiler can't guess what a caller will pass — so you annotate them. The return type is the opposite: the compiler can read every return statement in the body and work it out, so you usually don't have to.

A useful mental model is a contract with two signatures on it. The parameter types are your promise about what you accept; the return type is your promise about what you hand back. Callers only ever see the contract, never the body. Leaving the return type to inference means "the contract is whatever the body happens to do today"; writing it down means "the body must live up to this contract", and the compiler checks the body against it.

Under strict (specifically noImplicitAny), an unannotated parameter with no context is an error — TS7006: "Parameter 'x' implicitly has an 'any' type." The exception is a callback whose type is already known from where it's passed, such as the arrow inside .map(...); that's contextual typing.

TypeScript
function parsePort(input: string) {
  const n = Number(input);
  if (Number.isNaN(n)) return null;
  return n;
}
// Hover shows: function parsePort(input: string): number | null

const port = parsePort("8080");
port.toFixed(0); // ❌ 'port' is possibly 'null'.
What’s happening
  1. input: string is annotated because nothing else could tell the compiler what callers will pass. Leave it off and strict mode reports TS7006 instead of quietly making it any.
  2. The return type isn't written, so the checker collects every return in the body: return null contributes null, return n contributes number (Number(...) returns number). The inferred return type is the union of them: number | null.
  3. port therefore has type number | null. Calling port.toFixed(0) is rejected with TS18047: "'port' is possibly 'null'." — the compiler is reminding you about the NaN branch you wrote yourself.
  4. To use it you narrow first: if (port !== null) port.toFixed(0);. This is inference doing its job well: you never wrote number | null, but every caller is forced to handle both outcomes.
AdvancedAnnotating functions others call

Inference is great for small internal helpers. For functions other people call — exported functions, library APIs, module boundaries — write the return type down. The reason is where mistakes get reported:

TypeScript
type User = { id: number; name: string };
type Row = { id: number; full_name: string };

// No return type: the typo becomes part of the inferred type
function toUserInferred(row: Row) {
  return { id: row.id, nmae: row.full_name };
}
const a: User = toUserInferred({ id: 1, full_name: "Ada" }); // ❌ Property 'name' is missing in type '{ id: number; nmae: string; }' but required in type 'User'.

// Explicit return type: the typo is caught inside the function
function toUser(row: Row): User {
  return { id: row.id, nmae: row.full_name }; // ❌ Object literal may only specify known properties, and 'nmae' does not exist in type 'User'.
}
What’s happening
  1. toUserInferred has no return annotation, so whatever the body returns is the type. The checker happily infers { id: number; nmae: string } — the typo is now part of the function's public contract and nothing complains at the definition.
  2. The error only appears at a call site that expects a User: TS2741 "Property 'name' is missing in type '{ id: number; nmae: string; }' but required in type 'User'." In a real codebase that call site may be in another file, written by someone else, and the message points at their code rather than your bug.
  3. toUser declares : User. Now the returned object literal is checked against User right there, and excess property checking (see Excess property checking) flags the unknown key: TS2353 "Object literal may only specify known properties, and 'nmae' does not exist in type 'User'."
  4. Same bug, two very different places to find it. The explicit return type moves the error to the line that's actually wrong — that's the main reason to annotate public functions.
  5. Bonus: an explicit return type is cheaper for the checker (it doesn't have to analyse the body to type a call) and is required by isolatedDeclarations, which lets tools generate .d.ts files without running the full checker.
AdvancedRest parameters and function type expressions

A rest parameter collects the remaining arguments into an array, so its type is always an array or tuple type. A function type expression — (a: number, b: number) => number — is how you write the type of a function value, for callbacks, variables and properties.

TypeScript
function sum(...nums: number[]): number {
  return nums.reduce((total, n) => total + n, 0);
}
sum();        // 0
sum(1, 2, 3); // 6
sum(1, "2");  // ❌ Argument of type 'string' is not assignable to parameter of type 'number'.

type BinaryOp = (a: number, b: number) => number;

const multiply: BinaryOp = (x, y) => x * y;   // x and y are number from context
const firstOnly: BinaryOp = (x) => x;         // fewer parameters is fine
const tooMany: BinaryOp = (x: number, y: number, z: number) => x; // ❌ Type '(x: number, y: number, z: number) => number' is not assignable to type 'BinaryOp'.
What’s happening
  1. ...nums: number[] means "any number of arguments, each a number". Inside, nums is a real array, so reduce works. sum() gives nums = [] and returns the initial 0; sum(1, 2, 3) gives [1, 2, 3] → 6.
  2. sum(1, "2"): the checker matches each extra argument against the element type number. "2" fails with TS2345 "Argument of type 'string' is not assignable to parameter of type 'number'."
  3. type BinaryOp = (a: number, b: number) => number describes a function value. The names a and b are documentation only — an implementation can call them x and y. Because multiply is annotated BinaryOp, its parameters get their types from context, so no annotations are needed on x and y.
  4. firstOnly takes only one parameter, and that's allowed: a function that ignores the second argument is safe to call with two. This is the same rule that lets you pass (item) => … to forEach, which actually calls it with (item, index, array).
  5. tooMany requires a third argument that BinaryOp callers will never pass, so z would always be undefined. The assignment is rejected with TS2322 and the detail line says why: "Target signature provides too few arguments. Expected 3 or more, but got 2." The rule is "the implementation may need fewer parameters than the type provides, never more". (Its parameters are annotated because contextual typing gives up on a function that doesn't fit; without annotations you'd also get three TS7006 implicit-any errors.)

A rest parameter can also be a tuple, which gives you a fixed, named argument list you can reuse — handy for wrapping another function (see Named, optional and variadic tuples):

TypeScript
type LogArgs = [level: "info" | "warn", message: string, data?: object];

function log(...args: LogArgs) {
  const [level, message, data] = args;
  console[level](message, data ?? "");
}

log("info", "server started");
log("warn", "slow query", { ms: 812 });
log("debug", "x"); // ❌ Argument of type '"debug"' is not assignable to parameter of type '"info" | "warn"'.
What’s happening
  1. LogArgs is a tuple with labels (level, message, data). Labels don't change the type; they show up in editor hints so the signature reads like log(level, message, data?).
  2. ...args: LogArgs spreads the tuple into the parameter list: position 0 must be "info" | "warn", position 1 a string, position 2 an optional object. So log accepts two or three arguments.
  3. Inside, args is the tuple itself, so destructuring gives level: "info" | "warn", message: string, data: object | undefined. console[level] type-checks because both "info" and "warn" are methods on console.
  4. log("debug", "x") fails on the first element with TS2345 — the literal union rejects anything else. The same LogArgs type could be reused by a wrapper (function logTwice(...args: LogArgs)) without repeating the parameter list.

Practical guidance:

#Function overloads

JavaScript has no function overloading: if you declare two functions with the same name, the second simply replaces the first. TypeScript adds overload signatures: you write several call signatures stacked on top of one implementation. Callers see only the signatures; the implementation is the single real function that runs.

The problem it solves: some functions have a relationship between their inputs and their output that a single signature with unions can't express. "If you pass two numbers you get a number; if you pass two strings you get a string" is not the same as "pass number | string twice and get number | string back" — the union version forgets which case you're in, and it allows mixing.

From my notes: "You might ask, I could do it using type guards, so why do I need this? Because TypeScript will tell you if you use function overloading and you send 3 and "x" — meaning both have to be number or string." That's exactly it. A type guard narrows inside the function; overloads describe the allowed combinations to callers. Think of overloads as a menu: the caller must order one of the listed combinations, and they get the listed dish back.

First, the version without overloads:

TypeScript
function combine(a: number | string, b: number | string): number | string {
  if (typeof a === "number" && typeof b === "number") return a + b;
  return `${a}${b}`;
}

const total = combine(3, 4);     // type: string | number
total.toFixed(2);                // ❌ Property 'toFixed' does not exist on type 'string | number'.
const mixed = combine(3, "x");   // allowed — "3x"
What’s happening
  1. The signature says each parameter is independently number | string. Nothing links a to b, and nothing links the inputs to the output.
  2. combine(3, 4) really returns 7 at runtime, but the declared return type is number | string, so total is typed as the union. The caller has thrown away information it had (it passed two numbers).
  3. total.toFixed(2) fails with TS2339 "Property 'toFixed' does not exist on type 'string | number'." The caller would have to narrow with typeof again, even though the answer is obvious from the call.
  4. combine(3, "x") type-checks because 3 is number | string and "x" is number | string. The mixed call — the one you wanted to forbid — slips through and produces "3x".
Advancedcombine rewritten with overloads

Now with overloads:

TypeScript
function combine(a: number, b: number): number;
function combine(a: string, b: string): string;
function combine(a: number | string, b: number | string): number | string {
  if (typeof a === "number" && typeof b === "number") return a + b;
  return `${a}${b}`;
}

const total = combine(3, 4);       // type: number
const label = combine("a", "b");   // type: string
total.toFixed(2);                  // fine
combine(3, "x");                   // ❌ No overload matches this call.
What’s happening
  1. The first two lines are overload signatures — no body, just a shape. The third declaration, with a body, is the implementation signature. It's wide enough to handle every overload (number | string for each parameter) and is not visible to callers.
  2. combine(3, 4): the checker tries the overloads top to bottom and stops at the first that fits. Overload 1 wants (number, number) → matches, so the call's type is overload 1's return type: total: number. toFixed is now fine.
  3. combine("a", "b"): overload 1 fails on "a", overload 2 (string, string) matches → label: string.
  4. combine(3, "x"): overload 1 fails on "x" (not a number), overload 2 fails on 3 (not a string). There's no third option — the implementation signature doesn't count — so you get TS2769 "No overload matches this call." The message then shows the last overload's failure: "Argument of type 'number' is not assignable to parameter of type 'string'." That can look odd, since you might expect it to complain about "x"; it's just reporting why the last candidate failed.
  5. Inside the body, TypeScript only knows the implementation signature, so you still narrow with typeof — the overloads don't narrow anything for you.
AdvancedEmitted JavaScript for overloads

Overloads are pure type information. Here is what tsc emits for that function:

combine.js (emitted)
function combine(a, b) {
    if (typeof a === "number" && typeof b === "number")
        return a + b;
    return `${a}${b}`;
}
What’s happening
  1. Both overload signatures are gone — they had no bodies, so there's nothing to emit.
  2. The implementation loses its parameter and return annotations and becomes one ordinary JavaScript function.
  3. At runtime there is exactly one combine, and it has to handle every case itself by inspecting its arguments. TypeScript overloads change what callers are allowed to write, never which code runs — unlike Java or C#, where the compiler picks between separate method bodies.
AdvancedOverloads in the DOM and standard library

Where you meet overloads in real code: document.createElement, addEventListener and querySelector in lib.dom.d.ts; Array.prototype.reduce (with and without an initial value); Node's fs.readFile (returns a Buffer without an encoding, a string with one). Overloads also work on class methods and as multiple call signatures in an interface or object type.

What's the type of r, and does the last line compile?
TypeScript
function pad(value: string, width: number): string;
function pad(value: number, width: number): string;
function pad(value: string | number, width: number): string {
  return String(value).padStart(width);
}

declare const input: string | number;
const r = pad(input, 5);
Show answer

It does not compile:

TypeScript
function pad(value: string, width: number): string;
function pad(value: number, width: number): string;
function pad(value: string | number, width: number): string {
  return String(value).padStart(width);
}

declare const input: string | number;
const r = pad(input, 5); // ❌ No overload matches this call.
What’s happening
  1. input is string | number. The checker tries overload 1, which needs value: string — string | number isn't assignable to string, so it fails.
  2. Overload 2 needs value: number — string | number isn't assignable to number either. Each overload is checked against the whole union; the checker doesn't split the union and try each member separately.
  3. The implementation signature would accept string | number, but it is invisible to callers, so it's not a candidate.
  4. Fixes: add a third overload pad(value: string | number, width: number): string, or — better here — delete both overloads and keep just the union signature, since the return type is string either way. When overloads don't change the return type, they add nothing but this problem.

#More about overloads

The basic rule is "signatures on top, one implementation underneath". The details that trip people up — and that interviewers like to probe — are about which signatures are visible, which one wins, and how much the compiler really checks.

1. The implementation signature is not callable. Only the overload signatures are part of the function's type. If your implementation accepts something none of the overloads list, callers can't use it:

TypeScript
function format(value: number): string;
function format(value: Date): string;
function format(value: number | Date): string {
  return value instanceof Date ? value.toISOString() : value.toFixed(2);
}

format(3.14159);     // "3.14"
format(new Date(0)); // "1970-01-01T00:00:00.000Z"

declare const v: number | Date;
format(v); // ❌ No overload matches this call.
What’s happening
  1. The function's type, as callers see it, is just the two overloads: (value: number) => string and (value: Date) => string. The implementation's number | Date parameter isn't one of them.
  2. format(3.14159) matches overload 1; format(new Date(0)) matches overload 2. Both return string.
  3. format(v) with v: number | Date fails both overloads (each wants one specific type) and the implementation isn't a candidate, so TS2769.
  4. These overloads are pointless — the return type is the same for both — and they cost you the ability to pass a union. The handbook's guideline applies: prefer a union parameter over overloads whenever the return type doesn't depend on which member you pass. Replace all three declarations with function format(value: number | Date): string.
AdvancedOrder matters: specific overloads first

2. Order matters — put the most specific overload first. Resolution picks the first overload that fits, not the best one:

TypeScript
function describe(x: unknown): string;
function describe(x: string): "text";
function describe(x: unknown): string {
  return typeof x === "string" ? "text" : "other";
}

const d = describe("hi"); // type: string — the first overload won
What’s happening
  1. The checker walks the overloads in source order. For describe("hi"), overload 1 (x: unknown) accepts anything, so it matches immediately.
  2. Resolution stops there, so d gets overload 1's return type: string. Overload 2, the precise "text" one, is never reached — for any argument.
  3. Swap the two overload lines and describe("hi") matches (x: string): "text" first, so d: "text"; describe(42) falls through to the unknown overload and gets string.
  4. Rule of thumb: specific signatures (literal types, narrower types, more parameters) on top; catch-all signatures (unknown, any, wide unions) at the bottom.
AdvancedImplementation must match every overload

3. The implementation must be compatible with every overload. Each overload's parameters must be assignable to the implementation's parameters, and its return type must be compatible with the implementation's return type. Otherwise the overload is a promise the implementation can't even accept:

TypeScript
function parse(input: string): object;
function parse(input: Uint8Array): object; // ❌ This overload signature is not compatible with its implementation signature.
function parse(input: string): object {
  return JSON.parse(input);
}
What’s happening
  1. Overload 2 says callers may pass a Uint8Array. The implementation only declares input: string.
  2. The checker tests each overload against the implementation: a Uint8Array argument could not be passed to (input: string), so overload 2 is reported with TS2394 "This overload signature is not compatible with its implementation signature." The error sits on the overload, not the implementation.
  3. The fix is to widen the implementation to input: string | Uint8Array and handle both (for example with new TextDecoder().decode(input) for the bytes).
AdvancedThe body isn't checked per overload

4. The compiler does not check the body against each overload. This is the big one. The body is checked only against the implementation signature. If the implementation returns the wrong thing for a particular overload, nothing catches it:

TypeScript
function double(x: number): number;
function double(x: string): string;
function double(x: number | string): number | string {
  return typeof x === "number" ? `${x * 2}` : x + x; // bug: string for a number input
}

const d = double(4); // type: number, runtime value: "8"
d.toFixed(1);        // compiles — TypeError at runtime: d.toFixed is not a function
What’s happening
  1. The body is checked against the implementation signature (x: number | string): number | string. Both branches return a string, and string is assignable to number | string, so the body is "fine".
  2. The checker never asks "when x was a number, did you return a number?" — it has no way to connect a branch to an overload.
  3. The caller sees overload 1, so d: number. At runtime d is the string "8".
  4. d.toFixed(1) compiles and then throws TypeError: d.toFixed is not a function. Overloads are a promise you make to callers; the compiler takes your word for it, much like a type assertion. Keep overloaded implementations small and test each overload.
AdvancedMore overload rules

A few more rules worth knowing:

  • Arrow functions and function expressions can't have overload declarations. To overload a function value, give it a type with several call signatures — type Combine = { (a: number, b: number): number; (a: string, b: string): string } — but then the implementation must be assignable to every signature, which usually takes an assertion.
  • Overloads that differ only in arity are usually better as optional parameters: function slice(start: number, end?: number) beats two overloads.
  • Hover shows the overload count (+1 overload), and ReturnType<typeof fn> and Parameters<typeof fn> use the last overload — see ReturnType and Parameters.

#Generics are often better than overloads

Overloads list cases one by one. A generic describes the rule that connects them. When the output type is simply "the same kind of thing as the input" — or something computed from it — one generic signature replaces any number of overloads, keeps working for types you didn't list, and lets the compiler check the body properly.

A good test: if you'd write the same overload again for every new type, you want a generic. If the cases are genuinely irregular (string → number, number → Date, different numbers of arguments), overloads are fine. Generics are covered in depth in Generic functions; here the point is choosing between the two tools.

TypeScript
// Overloads: one line per element type, and it never ends
function firstOf(items: string[]): string | undefined;
function firstOf(items: number[]): number | undefined;
function firstOf(items: unknown[]): unknown {
  return items[0];
}
firstOf([true, false]); // ❌ No overload matches this call.

// Generic: one rule for every element type
function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}
const s = first(["a", "b"]);   // string | undefined
const b = first([true, false]); // boolean | undefined
const u = first([{ id: 1 }]);   // { id: number } | undefined
What’s happening
  1. The overloaded firstOf only knows the element types someone bothered to list. firstOf([true, false]) matches neither string[] nor number[], so TS2769 ("The last overload gave the following error. Type 'boolean' is not assignable to type 'number'.") — you'd need a third overload, then a fourth…
  2. first<T> declares a type parameter T. At each call the checker infers T from the argument: ["a", "b"] is string[], so T = string and the return type is string | undefined.
  3. The same signature works for boolean and for { id: number } — types nobody wrote down — because the rule "return an element of the array you passed" is written once.
  4. The body is checked properly too: items[0] has type T (plus undefined under noUncheckedIndexedAccess), which matches the declared T | undefined. Compare the overload version, where the implementation returned unknown and the compiler couldn't verify anything.
  5. readonly T[] accepts both mutable and readonly arrays, and promises the function won't modify the input.
AdvancedGeneric lookups instead of overloads

The bigger win is when there's a lookup between the input and output. Here's the pattern document.createElement uses (HTMLElementTagNameMap maps "div" to HTMLDivElement, and so on):

TypeScript
type ShapeProps = {
  circle: { radius: number };
  square: { side: number };
  rect: { width: number; height: number };
};

function area<K extends keyof ShapeProps>(kind: K, props: ShapeProps[K]): number {
  const p = props as ShapeProps[keyof ShapeProps];
  if ("radius" in p) return Math.PI * p.radius ** 2;
  if ("side" in p) return p.side ** 2;
  return p.width * p.height;
}

area("circle", { radius: 2 });           // ≈ 12.57
area("rect", { width: 2, height: 3 });   // 6
area("square", { radius: 2 });           // ❌ Object literal may only specify known properties, and 'radius' does not exist in type '{ side: number; }'.
What’s happening
  1. ShapeProps is a lookup table at the type level: each key is a shape name, each value is the props that shape needs.
  2. K extends keyof ShapeProps means K must be one of "circle" | "square" | "rect". From area("circle", …) the checker infers K = "circle" from the first argument.
  3. The second parameter's type is the indexed access ShapeProps[K] (see Indexed access types), so with K = "circle" it becomes { radius: number }. The second argument is checked against exactly that.
  4. area("square", { radius: 2 }): K = "square", so props must be { side: number }. The object literal has radius instead, which is reported with TS2353.
  5. Inside the body, TypeScript can't narrow props by checking kind (that's a known limitation with generic parameters), so the implementation widens props to the union of all prop types and narrows it with in. This is the trade-off: the call side is perfectly typed by one signature that grows automatically when you add a key to ShapeProps; the overload version would need one new overload per shape.
AdvancedA generic combine

What about the "both numbers or both strings" combine from Function overloads? A generic can enforce "same type for both", but it's not a clean fit:

TypeScript
function combine<T extends string | number>(a: T, b: T): T {
  return ((a as any) + b) as T;
}

const n = combine(3, 4);  // type: 3 | 4 — not number!
combine(3, "x");          // ❌ Argument of type '"x"' is not assignable to parameter of type '3'.
What’s happening
  1. Both parameters share T, so whatever T turns out to be, a and b must both fit it.
  2. For combine(3, 4), the checker collects the candidates 3 and 4. Because T's constraint includes primitives, it keeps the literal types and unions them: T = 3 | 4. So n is typed 3 | 4, while the real value is 7 — the return type is now a lie.
  3. For combine(3, "x"), the candidates 3 and "x" have no common supertype, so the checker fixes T to the first candidate, 3, and reports "x": TS2345 "Argument of type '"x"' is not assignable to parameter of type '3'." The mixed call is rejected, as intended, but the message is confusing.
  4. The body needed two assertions, because a + b on two Ts isn't allowed (T could be a number and the result could be a string, as far as the checker knows).
  5. Conclusion: when the output type isn't the input type but a different, fixed type per case (number → number and string → string happen to look similar but the sum 7 isn't the literal 3 | 4), overloads describe it more honestly. Generics win when the output is the input type or a lookup from it.
AdvancedGenerics, unions or overloads: a guide

#Typing async functions and promises

An async function always returns a promise, whatever its body returns — return "data" inside an async function resolves the promise with "data". TypeScript models that exactly: if the body returns values of type T, the function's return type is Promise<T>. await does the reverse: it takes a Promise<T> and gives you a T.

Mental model: Promise<T> is a sealed envelope with a T inside. An async function hands you the envelope immediately; await (or .then) is the only way to open it. Most async typing mistakes are trying to use the envelope as if it were the letter.

From my notes:

TypeScript
async function fetchData(): Promise<string> {
  return "data";
}

let result: string = await fetchData();       // Correct
let wrongResult: number = await fetchData();  // ❌ Type 'string' is not assignable to type 'number'.
What’s happening
  1. fetchData is declared to return Promise<string>. Inside the body you return "data" — a plain string, not a promise. The checker checks the returned value against the inner type string, because async wraps it for you.
  2. await fetchData(): calling the function gives a Promise<string>; await unwraps it, so the expression has type string. Assigning to result: string is fine.
  3. wrongResult: number = await fetchData() fails with TS2322 "Type 'string' is not assignable to type 'number'." — the unwrapped type is compared with the annotation exactly as for synchronous code.
  4. The top-level await here only works in an ES module (a file with import/export, with module set to es2022/esnext/nodenext). Inside a function, the function has to be async.
AdvancedCommon async typing mistakes

The common mistakes, each with the message you'll see:

TypeScript
async function getName(): Promise<string> {
  return "Ada";
}

async function bad(): string { // ❌ The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<string>'?
  return "Ada";
}

function forgotAwait() {
  const name = getName();
  return name.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'Promise<string>'.
}

const p = new Promise((resolve) => setTimeout(() => resolve(42), 10));
// const p: Promise<unknown>
const typed = new Promise<number>((resolve) => setTimeout(() => resolve(42), 10));
// const typed: Promise<number>
What’s happening
  1. bad(): string — an async function can't return a bare string, because at runtime it never does. TS1064 tells you to write Promise<string>.
  2. forgotAwait calls getName() without await, so name is the envelope, Promise<string>. name.toUpperCase() fails with TS2339 "Property 'toUpperCase' does not exist on type 'Promise<string>'." and the compiler adds a hint: "Did you forget to use 'await'?". Without TypeScript this is a classic silent bug ([object Promise] in your UI).
  3. new Promise((resolve) => …) with no type argument: nothing tells the checker what resolve will be called with, so it falls back to Promise<unknown>. Code that later awaits p gets unknown and has to narrow.
  4. new Promise<number>(…) supplies the type argument, so resolve is typed (value: number | PromiseLike<number>) => void — resolve("x") would now be an error — and typed is Promise<number>.
AdvancedTyped Promise.all for concurrent loading

Promise.all keeps each position's type, which makes concurrent loading pleasant:

TypeScript
type User = { id: number; name: string };
type Post = { title: string };

async function getUser(id: number): Promise<User> {
  return { id, name: "Ada" };
}
async function getPosts(userId: number): Promise<Post[]> {
  return [{ title: `Post by ${userId}` }];
}

async function loadProfile(id: number) {
  const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
  // user: User, posts: Post[]
  return { name: user.name, postCount: posts.length };
}
// loadProfile: (id: number) => Promise<{ name: string; postCount: number }>

type Profile = Awaited<ReturnType<typeof loadProfile>>;
// type Profile = { name: string; postCount: number }
What’s happening
  1. Promise.all([getUser(id), getPosts(id)]) receives an array literal of [Promise<User>, Promise<Post[]>]. Promise.all's declaration is generic over a tuple of values (T extends readonly unknown[] | []), so the checker keeps each position separate instead of collapsing to (User | Post[])[].
  2. It returns Promise<[User, Post[]]> — each element is unwrapped with Awaited. After await, destructuring gives user: User and posts: Post[]. Both requests run concurrently; await waits for both.
  3. loadProfile has no return annotation, so the checker infers it from the return: the object is { name: string; postCount: number }, and because the function is async, the return type becomes Promise<{ name: string; postCount: number }>.
  4. ReturnType<typeof loadProfile> gives that Promise<…>, and Awaited<…> unwraps it. This pairing is the standard way to name "what this async function resolves to" without writing the type twice — see Awaited<T>.
AdvancedPitfall: untyped promise rejections
AdvancedPitfall: async callbacks in forEach
AdvancedPractical async typing guidance

Practical guidance:

  • Annotate exported async functions as Promise<T>; the return statements are then checked against T.
  • Prefer async/await over .then chains — the types read the same as synchronous code.
  • Give new Promise a type argument, or wrap callback APIs with util.promisify / the node:fs/promises style APIs that are already typed.
  • Use Promise.all for independent work, Promise.allSettled when you want every result regardless of failures (its results are a discriminated union on status).

#Typing this

Added

In JavaScript, this inside a regular function is decided by how the function is called, not where it's written: obj.method() sets this to obj, a bare fn() sets it to undefined in strict mode, and call/apply/bind set it explicitly. Arrow functions don't have their own this at all; they use the this of the surrounding code.

That makes this hard to type. TypeScript gives you three tools: noImplicitThis (part of strict), which refuses to let this silently be any; the this parameter, a fake first parameter that declares what this must be; and arrow functions, which sidestep the problem by capturing this lexically. For class methods, this is automatically the class instance — but only as long as the method is called through an instance.

Mental model: the this parameter is a caller requirement written into the signature. Just as (id: number) says "call me with a number", (this: HTMLButtonElement) says "call me on a button". It's checked at every call site and then erased.

TypeScript
function shout() {
  return this.name.toUpperCase(); // ❌ 'this' implicitly has type 'any' because it does not have a type annotation.
}

function onClick(this: HTMLButtonElement, event: MouseEvent) {
  console.log(this.textContent, event.clientX);
}

const button = document.createElement("button");
button.addEventListener("click", onClick); // fine — the DOM calls it with this = button
onClick(new MouseEvent("click"));          // ❌ The 'this' context of type 'void' is not assignable to method's 'this' of type 'HTMLButtonElement'.
onClick.call(button, new MouseEvent("click")); // fine
What’s happening
  1. shout is a standalone function that uses this. Nothing says what this is, so it would be any; noImplicitThis turns that into TS2683 "'this' implicitly has type 'any' because it does not have a type annotation."
  2. onClick(this: HTMLButtonElement, event: MouseEvent) declares this. It's not a real parameter — callers pass only event — but inside the body this.textContent is now checked against HTMLButtonElement.
  3. button.addEventListener("click", onClick): the DOM types declare the listener as (this: HTMLButtonElement, ev: MouseEvent) => any for a button, so the declared this matches. At runtime the browser calls the listener with this set to the button.
  4. onClick(new MouseEvent("click")) is a bare call, so this would be undefined at runtime. The checker models a bare call's this as void and reports TS2684 "The 'this' context of type 'void' is not assignable to method's 'this' of type 'HTMLButtonElement'." — a crash caught at compile time.
  5. onClick.call(button, …) supplies this explicitly and type-checks: call is typed to take the this value as its first argument.
AdvancedThe this parameter in emitted code

The this parameter is erased completely. Here's the emitted JavaScript for onClick:

onClick.js (emitted)
function onClick(event) {
    console.log(this.textContent, event.clientX);
}
What’s happening
  1. The this: HTMLButtonElement "parameter" is removed, so event is the first real parameter again — exactly what the browser passes.
  2. Nothing checks this at runtime. The protection exists only at the call sites the compiler saw; a call from plain JavaScript could still pass the wrong this.
AdvancedDetached methods lose this

Detached methods

Inside a class, this in a method is typed as the instance. But TypeScript, by default, does not stop you from pulling the method off the object, which loses this at runtime:

TypeScript
class Counter {
  count = 0;
  increment() {
    this.count++;
  }
  safeIncrement(this: Counter) {
    this.count++;
  }
  arrowIncrement = () => {
    this.count++;
  };
}

const counter = new Counter();
const inc = counter.increment;
inc(); // compiles — TypeError at runtime: Cannot read properties of undefined (reading 'count')

const safe = counter.safeIncrement;
safe(); // ❌ The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'.

const arrow = counter.arrowIncrement;
arrow(); // fine at compile time and at runtime — this is captured
What’s happening
  1. increment() is a normal method on Counter.prototype. Its this is typed this (the instance), but the method has no declared this parameter, so the checker doesn't check how it's called.
  2. const inc = counter.increment copies the function, not the binding to counter. inc() is a bare call, so at runtime this is undefined (class bodies are strict mode) and this.count++ throws TypeError: Cannot read properties of undefined (reading 'count'). TypeScript doesn't catch it.
  3. safeIncrement(this: Counter) makes the requirement explicit. safe() is a bare call whose this is void, so TS2684 fires at compile time.
  4. arrowIncrement is a class field holding an arrow function. It's created in the constructor for each instance and captures that instance's this lexically, so arrow() works even when detached. The trade-off: one function per instance instead of one shared prototype method, and it can't be overridden with super.arrowIncrement().
  5. The arrow function can't declare a this parameter (TS2730 "An arrow function cannot have a 'this' parameter.") — it doesn't have its own this to describe.
AdvancedReturning this for fluent APIs

Inside a class, this is also a type: "the type of whatever instance this method was called on". Returning it makes chaining work in subclasses too:

TypeScript
class QueryBuilder {
  protected parts: string[] = [];
  where(clause: string): this {
    this.parts.push(`WHERE ${clause}`);
    return this;
  }
}

class PagedQueryBuilder extends QueryBuilder {
  limit(n: number): this {
    this.parts.push(`LIMIT ${n}`);
    return this;
  }
}

const q = new PagedQueryBuilder().where("active").limit(10);
// q: PagedQueryBuilder
What’s happening
  1. where declares its return type as this, not QueryBuilder. The polymorphic this type means "the type of the receiver at the call site".
  2. new PagedQueryBuilder().where("active"): the receiver is a PagedQueryBuilder, so this resolves to PagedQueryBuilder and where returns that type.
  3. That's why .limit(10) is allowed next. If where were typed : QueryBuilder, the chain would lose the subclass type and .limit would be an error ("Property 'limit' does not exist on type 'QueryBuilder'").
  4. At runtime nothing special happens — return this returns the same object every time. The this return type only affects what the checker lets you chain.

Practical guidance:

  • Keep strict on so noImplicitThis catches untyped this.
  • Use a this parameter for functions meant to be called with a specific receiver (DOM handlers, functions installed onto prototypes, library hooks).
  • For methods passed as callbacks (onClick={this.handleClick}, setTimeout(this.tick)), use an arrow-function field or .bind(this) in the constructor.
  • For typing this inside object literals passed to builder-style helpers, see ThisType<T>.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.