Skip to main content
CodeOath
← All posts

TypeScript63 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Part 10 of 21 · ~2 min

Functions, Overloads, and this

A function's type can be named and passed around independently of any particular function, which is the whole basis for callbacks:

type RankingRule<T> = (a: T, b: T) => number;

function orderByRule<T>(items: T[], rule: RankingRule<T>): T[] {
  return [...items].sort(rule);
}

Sometimes one function name genuinely needs more than one valid signature, because what it returns depends on which shape of input it got — something a single union parameter can't express without forcing every caller to narrow the result themselves. Overloads let you declare the specific input/output pairings separately from the implementation:

function exportApplicant(format: "csv"): string;
function exportApplicant(format: "json"): Record<string, unknown>;
function exportApplicant(format: "csv" | "json"): string | Record<string, unknown> {
  if (format === "csv") return "id,name,email\n8841,Priya Shah,priya@example.com";
  return { id: 8841, name: "Priya Shah", email: "priya@example.com" };
}

const row = exportApplicant("csv");     // string
const record = exportApplicant("json"); // Record<string, unknown>
row.toUpperCase();     // fine
record.toUpperCase();  // Error — this signature never promised a string

The version with a body — the last one — is the only one that actually runs; the two above it are just the menu of allowed calls, and callers only ever see those, never the combined signature underneath.

There's a second, less obvious use of function typing worth having in your toolkit: pinning down what this is allowed to be inside a plain function, which catches a function being handed to the wrong caller before anyone ever calls it that way.

interface QueueControls {
  name: string;
  size: number;
}

function logDequeue(this: QueueControls, applicantId: number) {
  console.log(`${this.name} dequeued #${applicantId}`);
}

const engControls: QueueControls = { name: "engineering", size: 3 };
logDequeue.call(engControls, 8841); // fine — this is explicitly engControls here

type DequeueHandler = (applicantId: number) => void;
const handler: DequeueHandler = logDequeue;
// Error: The 'this' context of type 'void' is not assignable to
// method's 'this' of type 'QueueControls'.

It's worth being exact about when this actually fires, because the coverage is narrower than it first looks. TypeScript catches the assignment above because handler is declared with an explicit function type, DequeueHandler, that has no this of its own — and logDequeue needs a real QueueControls to run correctly. Call logDequeue(8841) directly, with no assignment in between, and nothing complains, even though the runtime behavior would be just as broken. And — a genuinely easy way to get burned — assigning the function straight onto an object property, rather than through a named function type, often doesn't trigger the check either. Use a this-typed function where a real receiver actually matters, but confirm the specific way you're wiring it up is one the compiler is actually watching.