You've typed variables, arrays, and object shapes. Functions are next — and they're where most of TypeScript's payoff lives. Every function in your test code is a contract: "give me these inputs, I'll give you this output." Annotate that contract once and the compiler enforces it everywhere the function is called. This lesson covers parameter annotations, return types, the function-as-a-type idiom, void, and Promise<T> for async code.
Always type the parameters
The single most important habit in TypeScript: annotate every function parameter. Under strict mode, the compiler refuses to let an un-annotated parameter slip through.
function validateResponse(statusCode: number, body: string, maxTime: number): boolean {
return statusCode === 200 && body.length > 0 && maxTime < 2000;
}
validateResponse(200, "OK", 1500); // ✅
validateResponse("200", "OK", 1500);
// ❌ Argument of type 'string' is not assignable to parameter of type 'number'.In JavaScript for QA you wrote functions that accepted whatever, and discovered the wrong-type-passed-in bug at runtime. The annotations above push that bug back to the line that called the function — usually the moment you typed it.
Return type annotations
The : boolean after the parameter list is the function's return type. TypeScript will infer the return type if you leave it off, but writing it explicitly is worth the few characters:
- It documents the contract at the function signature, where most readers look.
- It catches bugs where you accidentally return the wrong shape (an explicit annotation is checked against every
returnstatement).
function getStatus(passed: boolean): string {
if (passed) return "✅";
// ❌ Function lacks ending return statement and return type does not include 'undefined'.
}Without : string, TypeScript would infer the return type as string | undefined and quietly let the bug through. The annotation is the difference between "compiler caught the missing branch" and "test crashed at midnight."
Arrow functions take the same types
Arrow functions use the same annotation pattern — types after each parameter, return type after the parameter list:
const formatDuration = (ms: number): string => {
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
};
formatDuration(450); // "450ms"
formatDuration(1850); // "1.9s"Arrow functions are the JavaScript for QA form you already know. The only addition is the : number and : string annotations.
Functions as types — function type aliases
You can give a function signature a name and reuse it. This is how you describe "a callback that takes X and returns Y":
type Validator = (value: string) => boolean;
const isNonEmpty: Validator = (value) => value.length > 0;
const isEmail: Validator = (value) => value.includes("@");
const isShort: Validator = (value) => value.length <= 10;Notice the parameter value doesn't need its own annotation in the body — TypeScript reads the Validator type and infers the parameter as string. The return type is inferred too. Annotate at the boundary; let inference handle the inside, exactly the pattern from chapter 1.
void — functions that don't return anything useful
When a function runs side effects and doesn't return a value callers should rely on, the return type is void:
function logTestResult(name: string, passed: boolean): void {
console.log(`${passed ? "✅" : "❌"} ${name}`);
}
const x = logTestResult("Login", true);
// x is typed as `void` — TypeScript signals "don't use this".void is similar to undefined but signals intent — "callers should not depend on the return value." Use it for loggers, setup hooks, fire-and-forget event handlers, and any test helper whose job is the side effect.
Promise<T> — async functions
Async functions always return a Promise. The type parameter (<T>) is whatever the function eventually resolves to:
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
const alice = await fetchUser(1);
console.log(alice.name); // ✅ alice is User after the awaitAnything you return from an async function is wrapped in a Promise. Anything an await waits on is unwrapped from its Promise. The two are inverses, and TypeScript tracks both ends. Async typing is one of the highest-leverage uses of TypeScript in API testing — the response shape is now part of the function signature.
A small but important reminder: response.json() returns Promise<any> by default, which means the cast to User above is taken on faith. For untrusted input, parse as unknown and validate with a runtime checker (Zod, io-ts) — exactly the pattern from chapter 1.
A QA-shaped helper
A typed function that builds API request headers — the kind of helper every test framework has somewhere:
type Headers = Record<string, string>;
function buildHeaders(token: string, contentType: string = "application/json"): Headers {
return {
"Authorization": `Bearer ${token}`,
"Content-Type": contentType,
"X-Test-Run": process.env.TEST_RUN_ID ?? "local",
};
}
const headers = buildHeaders("abc-123");
// type: { [key: string]: string }Record<string, string> is shorthand for "an object with string keys and string values" — a built-in utility type. The function is now self-documenting: every caller sees that token is required and contentType defaults to JSON.
Data flow through a typed function
Step 1 of 4
Caller passes arguments
Each value is checked against the parameter's annotated type. Wrong type? Compile error before the call runs.
Every arrow in that diagram is a place TypeScript checks types. Skip annotations and you skip checks; annotate the boundaries and the entire pipeline becomes self-verifying.
⚠️ Common mistakes
- Skipping return type annotations on exported functions. Inference works fine for tiny private helpers. But once a function is exported — used by another file or another package — the explicit return type acts as the contract. Without it, a refactor that quietly changes what's returned can ripple through the codebase before anyone notices.
- Returning different types from different branches.
function getStatus(p: boolean) { if (p) return "ok"; return 200; }infers a return type ofstring | number, which forces every caller to narrow before reading. Pick one type and stick with it; if multiple shapes are legitimate, declare them with a union and document the contract. - Declaring an async function without
Promise<T>. Forgetting thePromise<>wrapper compiles, because TypeScript adds it for you. But declaring a return type like: Useron an async function is a compile error — it must be: Promise<User>. The mistake usually surfaces when you try to return a plain User object and the compiler refuses to flatten the promise.
🎯 Practice task
Type a small API helper. 20-30 minutes.
-
In your
ts-for-qa/srcfolder, createapi.ts. -
Define
interface User { id: number; name: string; email: string; isActive: boolean }. -
Write
function buildHeaders(token: string, contentType: string = "application/json"): Record<string, string>that returns the auth headers. -
Write
async function fetchUser(id: number, token: string): Promise<User>that calls a fake endpoint (mock the fetch with a hardcoded return for now). Annotate everything. -
Write a function-as-a-type-alias:
const formatUser: (u: User) => string = (u) => `${u.name} <${u.email}>`; -
Call
await fetchUser(1, "abc-123")and pass the result toformatUser. Run withnpx ts-node src/api.ts. -
Trigger every annotation check. After each, read the error and revert:
- Pass
fetchUser("1", "abc-123")(string id). - Make
formatUserreturn a number. - Drop the
Promise<User>annotation and try to return a plain object.
- Pass
-
Stretch: define
type Validator = (value: string) => booleanand write three validators —isNonEmpty,isEmail,hasMinLength(curried so the minimum is configurable). Confirm the compiler enforces the signature on all three.
The next lesson covers parameters that aren't always required — optional, default, and rest — the everyday tools for flexible test helpers.