Typed Functions and APIs

Functions are where types earn their salary: every parameter and return value is a contract enforced at every call site. In JavaScript, a function's signature lives in documentation (and drifts); in TypeScript it lives in the code — and the compiler rejects every call that drifts from it.

Anatomy of a typed signature

The vocabulary is small, but each piece changes what bugs become impossible:

Parameters, returns, optional, default, rest

Purpose of the example: one function showing every signature position and what the checker enforces at each.

// `timeout?` and `retries = 3` model "caller may omit";
// `...tags` collects the rest — all plain JS syntax, now checked.
function request(
  url: string,                 // required positional
  timeout?: number,            // optional — type is number | undefined
  retries = 3,                 // default — inferred as number
  ...tags: string[]            // rest — array or nothing
): Promise {           // explicit return type: the contract
  return Promise.resolve(`${url} t=${timeout ?? 0} r=${retries} [${tags.join(",")}]`);
}

request("/users");                                  // OK
request("/users", 5000, undefined, "cache", "api"); // OK — skipped optional
request("/users", "5000");                          // ✗ TS2345: string not assignable
request();                                          // ✗ TS2554: url is required

What you should see: two accepted calls and two precise errors — TS2345 for the wrong type, TS2554 for the missing argument. In plain JavaScript, the wrong-type call silently coerces and fails at runtime, possibly far from the cause.

What we learn: annotate the return type on exported functions even though inference could: the explicit type keeps the contract stable when the body changes, and stops return-type changes from rippling invisibly into every caller.

Inference lets you annotate less — not never

The checker is excellent at inferring types from initializers: const rate = 0.5 is number, const tags = ["a", "b"] is string[]. Annotate where the contract must hold: parameters (always), public returns (always), and local variables (only when inference would be too wide or wrong). Sprinkling annotations on obvious locals is noise that hides the annotations that matter.

void, never, and the return-type vocabulary

Two return types are frequently confused; they describe opposite guarantees.

void versus never

Purpose of the example: distinguish "returns nothing useful" from "never returns at all".

// void: returns undefined — callers shouldn't use the result.
// (Callbacks passed to forEach etc. are typed this way.)
function log(message: string): void {
  console.log(`[log] ${message}`);
}

// never: the function NEVER completes normally — it throws or loops forever.
// The checker uses this to prove code below the call is unreachable.
function fail(message: string): never {
  throw new Error(message);
}

function assertNever(x: never): never {   // exhaustiveness helper
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

What you should see: both compile. The difference is a guarantee: after fail("boom"), the checker knows the next statement cannot execute. You will meet assertNever again in the type-system lesson's exhaustiveness pattern — it is the standard idiom built on never.

Overloads: one name, several signatures

JavaScript has no overloading — functions accept anything. TypeScript can declare multiple signatures for one implementation, but the pattern is narrower than you expect.

When overloads are worth it

Purpose of the example: a createElement-style API where the argument shape determines the return type — the legitimate overload use case.

// Signatures (visible to callers' types; the body never sees them)
function parse(input: string): Record;
function parse(input: string[]): string[];
function parse(input: string | string[]): Record | string[] {
  if (Array.isArray(input)) {
    return input;                       // string[] branch
  }
  return Object.fromEntries(new URLSearchParams(input)); // record branch
}

const asRecord = parse("a=1&b=2");  // type: Record
const asList   = parse(["a", "b"]); // type: string[]
// asRecord.toUpperCase — ✗ Property does not exist on Record

What you should see: each call gets the exact matching return type — impossible to express with a plain union return. The implementation signature itself is not callable from outside; it only constrains the implementation.

What we learn: prefer a union return or a discriminated argument when you can; add overloads only when callers must get different return types per argument shape. Order matters (first match wins), and more than two or three overloads is usually a design smell.

Typing async contracts

async functions return Promise<T>, and the checker verifies what you await — turning broken promise chains into compile errors.

The result union, not try/catch everywhere

Purpose of the example: encode success and failure in the return type itself, following the discriminated-union pattern from the type-system lesson.

type Result =
  | { ok: true; value: T }
  | { ok: false; error: string };

async function fetchJson(url: string): Promise> {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return { ok: false, error: `HTTP ${res.status}` };
    }
    return { ok: true, value: (await res.json()) as T }; // payload verified elsewhere
  } catch (cause) {
    return { ok: false, error: String(cause) };
  }
}

const out = await fetchJson<{ id: string }>("/api/user/7");
if (out.ok) {
  console.log(out.value.id);   // value exists only in the ok branch
} else {
  console.error(out.error);    // and error only here
}

What you should see: reading out.value before the if is an error; narrowing guarantees the payload is only touched on success. Note that catch variables are typed unknown under strict mode — you must stringify or check them, which closes the "throw a string" bug class.

Typing callbacks

Callbacks are functions as parameters; type them like any other function:

function mapWait(
  items: T[],
  transform: (item: T, index: number) => R   // full signature inline
): R[] {
  return items.map(transform);
}

const lengths = mapWait(["alpha", "beta"], (s) => s.length);
// s and the return value are inferred from the contract — no annotations needed

What you should see: the arrow body is checked against the declared contract: return s + 1 and the checker errors. What we learn: annotate the callback's parameters in the contract once, and every caller's lambda is checked for free — this is exactly how library APIs like Array.prototype.map give you typed callbacks with zero configuration.

Practice: design a real API

Typed pagination

Build fetchPage returning Promise<Result<Page<Item>>> where Page carries items: Item[], nextCursor?: string, and a total count. Requirements: an explicit return type, a rest parameter for filters, and an optional callback onProgress?: (loaded: number) => void. What you should see: every misuse of the API — wrong filter types, forgetting the failure branch — surfaces as a red squiggle at the call site, not as a production incident.

Refactor drill

Find a JavaScript function in an old project that returns "an object or null depending on the day". Give it a Result-style or discriminated return type and watch the checker list every caller that forgot the null case. That discovery list is the refactor TypeScript just did for you.