Typing Callbacks and Higher-Order Functions

8 min read

In JavaScript for QA you learned to pass functions as arguments — users.filter(u => u.role === "admin"), results.forEach(logResult), setTimeout(retry, 1000). TypeScript adds the type machinery that makes those callbacks safe: typed parameters inside the callback, autocomplete on the values you're handed, compile errors when the callback's signature doesn't match what the receiver expects. This lesson covers callback typing, higher-order functions, and the retry pattern every test framework eventually needs.

A callback is just a function-typed parameter

A callback parameter has the same (args) => return shape as a function-type alias. Annotate the receiver, and every caller's callback is checked against the contract:

interface TestResult {
  name: string;
  passed: boolean;
  duration: number;
}
 
function processResults(
  results: TestResult[],
  callback: (result: TestResult) => void
): void {
  results.forEach(callback);
}
 
processResults(allResults, (r) => console.log(r.name));   // ✅
processResults(allResults, (r) => r.length);
// ❌ Property 'length' does not exist on type 'TestResult'.

Inside the callback, r is typed as TestResult automatically — TypeScript reads the parameter annotation on processResults and infers r. Autocomplete shows name, passed, duration; typos and wrong-property accesses are caught at compile time.

Naming callback shapes with type aliases

When the same callback shape shows up in multiple places, give it a name:

type ResultHandler     = (result: TestResult) => void;
type ResultFilter      = (result: TestResult) => boolean;
type ResultTransformer = (result: TestResult) => string;
 
function processResults(results: TestResult[], handler: ResultHandler): void {
  results.forEach(handler);
}
 
function filterResults(results: TestResult[], pred: ResultFilter): TestResult[] {
  return results.filter(pred);
}

The aliases become the documentation: a ResultFilter returns a boolean, a ResultTransformer returns a string. Anywhere those names appear in a function signature, readers know exactly what shape they need to provide.

Higher-order functions — functions that return functions

A higher-order function takes or returns another function. The classic test-suite use is a configurable validator:

function createValidator(maxDuration: number): (result: TestResult) => boolean {
  return (result) => result.duration <= maxDuration;
}
 
const isUnder2Seconds = createValidator(2000);
const isUnder500ms    = createValidator(500);
 
const fastResults = results.filter(isUnder500ms);
const okResults   = results.filter(isUnder2Seconds);

createValidator returns a function whose type is (result: TestResult) => boolean. The returned function captures the maxDuration value from the enclosing call — that's a closure, the JavaScript pattern you already know, now type-checked end to end.

This pattern is the cleanest way to build a library of small, configurable predicates. Reach for it whenever a callback's behaviour depends on a value that doesn't change per call.

Inference does most of the work in array methods

You don't usually have to annotate the callback parameter for .filter, .map, or .forEach. TypeScript reads the array type and infers the element type for you:

const results: TestResult[] = getResults();
 
const failed = results.filter(r => r.status === "failed");
//                              ^ r is inferred as TestResult
 
const names = results.map(r => r.name);
//      ^ names is inferred as string[]
 
const totalMs = results.reduce((sum, r) => sum + r.duration, 0);
//                                   ^ r: TestResult, sum: number

This is one of the most pleasant parts of TypeScript. Annotate at the boundary (the array's type), and the inside of every callback is typed automatically. Refactor the array's element type and every callback updates in lockstep.

(One small wart: TestResult here uses status: "failed", not passed: boolean. Consistency between code samples in your test suite keeps the compiler quiet — pick one shape per project and stick to it.)

A typed retry helper — the daily-driver pattern

Every test framework eventually needs "retry this action a few times before giving up." Typing it correctly means the return type flows through automatically — the caller gets Promise<User> if they passed a function returning Promise<User>, and Promise<string> if they passed one returning Promise<string>.

async function retry<T>(
  action: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await action();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw new Error("Unreachable");
}
 
const user = await retry(() => fetchUser(1));
//    ^ user is User — return type flowed through
 
const html = await retry(() => fetchHtml("/login"), 5, 500);
//    ^ html is string — different T, same retry helper

The <T> is a generic — covered in detail in the next chapter. Read it for now as "whatever the action returns, retry returns the same thing." The compiler tracks the type per call site.

How a higher-order function flows through a test pipeline

Step 1 of 4

Configure

createValidator(2000) — returns a new function that captures the 2000ms limit. Type: (r: TestResult) => boolean.

Every arrow in that flow is checked. Annotate at the boundaries (your higher-order function's signature, the array's element type, the result variable) and inference threads the types through every callback in between.

A small QA-shaped pipeline

Combining everything from this lesson:

type ResultFilter = (r: TestResult) => boolean;
 
function makeDurationFilter(maxMs: number): ResultFilter {
  return (r) => r.duration <= maxMs;
}
 
function makeStatusFilter(status: "passed" | "failed" | "skipped"): ResultFilter {
  return (r) => r.passed === (status === "passed");
}
 
const fastFailures = results
  .filter(makeStatusFilter("failed"))
  .filter(makeDurationFilter(500))
  .map((r) => `${r.name} (${r.duration}ms)`);
 
console.log(fastFailures);

Two higher-order functions, one chained pipeline, every callback parameter typed without a single annotation inside the lambda. This is what a mature TypeScript test helper looks like.

⚠️ Common mistakes

  • Re-annotating callback parameters that inference already typed. results.filter((r: TestResult) => r.passed) is correct but redundant — the array type already provides it. Trust inference inside callbacks; annotate the receiver's signature instead.
  • Returning the wrong shape from a higher-order function. function makeFilter(x: number): (r: TestResult) => boolean declared, but the body returns (r) => r.duration (a number, not a boolean). The compiler catches it — but only if the return type is annotated. Annotate the returned function's signature on the higher-order function's return type, especially when the body is inline arrow chains.
  • Treating void callbacks as if they returned a value. A callback typed (r: TestResult) => void may technically return undefined, but callers should not depend on it. Trying to chain .filter after .forEach (which is void) is a classic JavaScript-into-TypeScript hangover; forEach doesn't produce a new array, full stop.

🎯 Practice task

Build a typed result-processing pipeline. 20-30 minutes.

  1. In your ts-for-qa/src folder, create pipeline.ts.
  2. Define interface TestResult { name: string; passed: boolean; duration: number }. Hard-code an array of five sample results.
  3. Define type ResultHandler = (r: TestResult) => void, type ResultFilter = (r: TestResult) => boolean, type ResultTransformer = (r: TestResult) => string.
  4. Write function processResults(results: TestResult[], handler: ResultHandler): void and call it with a printer that logs name and a ✅/❌ icon.
  5. Write a higher-order function makeDurationFilter(maxMs: number): ResultFilter and use it with .filter to keep only fast results.
  6. Write a higher-order function makeFormatter(prefix: string): ResultTransformer and use it with .map to produce labelled summary strings.
  7. Run with npx ts-node src/pipeline.ts.
  8. Trigger the inference checks. After each, read the error and revert:
    • Make processResults accept (r: TestResult) => string — pass a logger that returns void anyway.
    • Have makeDurationFilter return (r) => r.duration (a number, not a boolean).
    • Try results.filter(r => r.statusCode) — typo in property name.
  9. Stretch: type and use the retry<T> helper from the lesson. Pass a function returning Promise<TestResult[]> and confirm the resolved type flows through. (You'll meet generics in chapter 5; for now, use it as a teaser of how clean the types stay when the relationship is parametric.)

That's chapter 4 wrapped. You can now type any function — parameters, return types, callbacks, higher-order constructions, async pipelines — to the same standard a senior TypeScript engineer would. The next chapter is the one most people quietly worry about and end up loving: generics.

// tip to track lessons you complete and pick up where you left off across devices.