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: numberThis 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 helperThe <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) => booleandeclared, 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
voidcallbacks as if they returned a value. A callback typed(r: TestResult) => voidmay technically returnundefined, but callers should not depend on it. Trying to chain.filterafter.forEach(which is void) is a classic JavaScript-into-TypeScript hangover;forEachdoesn't produce a new array, full stop.
🎯 Practice task
Build a typed result-processing pipeline. 20-30 minutes.
- In your
ts-for-qa/srcfolder, createpipeline.ts. - Define
interface TestResult { name: string; passed: boolean; duration: number }. Hard-code an array of five sample results. - Define
type ResultHandler = (r: TestResult) => void,type ResultFilter = (r: TestResult) => boolean,type ResultTransformer = (r: TestResult) => string. - Write
function processResults(results: TestResult[], handler: ResultHandler): voidand call it with a printer that logsnameand a ✅/❌ icon. - Write a higher-order function
makeDurationFilter(maxMs: number): ResultFilterand use it with.filterto keep only fast results. - Write a higher-order function
makeFormatter(prefix: string): ResultTransformerand use it with.mapto produce labelled summary strings. - Run with
npx ts-node src/pipeline.ts. - Trigger the inference checks. After each, read the error and revert:
- Make
processResultsaccept(r: TestResult) => string— pass a logger that returns void anyway. - Have
makeDurationFilterreturn(r) => r.duration(a number, not a boolean). - Try
results.filter(r => r.statusCode)— typo in property name.
- Make
- Stretch: type and use the
retry<T>helper from the lesson. Pass a function returningPromise<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.