Some functions behave differently based on the type of what you pass in. Pass a single id and you get a single record back; pass an array of ids and you get an array of records. Pass format: "json" and you get an object; pass format: "text" and you get a string. Function overloads are how you tell TypeScript "the return type depends on the argument type." This lesson covers when to reach for overloads, how to write them, and why most QA code should reach for unions or generics first.
The problem overloads solve
A function that accepts either one id or an array of ids:
The function works at runtime, but the return type is too broad. Every caller has to check whether the result is an array before they can use it — even though they know what they passed in. That's where overloads pay off.
Overload signatures
You declare each call signature first, then the implementation. Callers only see the signatures; the implementation is the worker that handles all of them.
The first two lines (overload signatures) are pure declarations. The third (implementation signature) is the function body the runtime actually executes. Callers see only the overloads — they pick the matching one based on argument types and get the precise return type.
A few rules:
The implementation signature is not visible to callers. It exists only to host the body. Make it broad enough to cover every overload.
Every overload signature must be callable through the implementation signature — the implementation has to accept everything the overloads promise.
TypeScript picks the first matching overload from the list. Order matters when signatures could overlap — put more specific overloads before broader ones.
A response parser with two overloads
A real-world QA example: a helper that parses an HTTP response body into either an object or a string, based on a format flag.
function parseResponse(response: string, format: "json"): object;function parseResponse(response: string, format: "text"): string;function parseResponse(response: string, format: "json" | "text"): object | string { return format === "json" ? JSON.parse(response) : response;}const data = parseResponse('{"ok":true}', "json");// type: object — TypeScript picked the first overloadconst raw = parseResponse("hello", "text");// type: string — TypeScript picked the second overloadparseResponse("hello", "xml");// ❌ Argument of type '"xml"' is not assignable to parameter of type '"json"'.
Callers get the precise return type for the format they asked for. No conditional narrowing needed at every call site.
How TypeScript picks an overload
getTestData(input)A call site reaches the function. TypeSc…
Match #1: input is string?If yes, return type is TestCase. The che…
Match #2: input is string[]?If yes, return type is TestCase[]. The c…
Neither matches → compile errorIf no overload accepts the argument type…
The key insight: the implementation signature is invisible to callers. Even though it accepts string | string[] and returns TestCase | TestCase[], those types are not what callers see. Only the overload signatures count.
When overloads earn their keep
The right time to reach for overloads is narrow:
Return type genuinely depends on the input type or value. Like the parse-response example — the format determines the shape.
A union type or generic doesn't capture the relationship. Sometimes the precise correlation between input and output isn't expressible without overloads.
You're typing an existing JavaScript function with multiple call shapes, and the call sites already exist.
Most of the time, simpler tools handle the job:
Union types — when the return is T | U regardless of the input. function classify(x: string | number): "small" | "big" doesn't need overloads.
Generics — when the input and output are related but parametric. function first<T>(arr: T[]): T is far cleaner than overloading for every element type. (You'll meet generics properly in the next chapter.)
Splitting into two functions — sometimes getTestCase(id) and getTestCases(ids) is just clearer than one overloaded function.
If you can solve the problem with a union or a generic, do that. Overloads are a heavier tool with more rules to remember, more edge cases to test, and more rope to hang yourself with.
Where you'll see overloads in QA tooling
Cypress's cy.get is overloaded — pass a selector string and get a Chainable for one element; pass <HTMLInputElement> as a generic and the chainable is typed accordingly. Playwright's page.waitForResponse is overloaded — pass a URL pattern, a function predicate, or a regex and the matching shape is preserved.
You probably won't write overloads often in your own test code. But understanding them lets you read framework type definitions without flinching, and it explains why the same call returns different types depending on the arguments you pass.
⚠️ Common mistakes
Forgetting that the implementation signature isn't a callable overload. Callers can only invoke the function with shapes that match one of the overload signatures. The implementation signature with string | string[] is for the body, not for outside use. If you find yourself wanting callers to pass string | string[], add a third overload that accepts the union — don't expect them to "see through" to the implementation.
Ordering overloads from broad to specific. TypeScript picks the first match. If your first overload is function f(x: string | number): string, a more specific function f(x: number): number declared later will never be picked. Put specific overloads first.
Reaching for overloads when generics would do.function first<T>(arr: T[]): T cleanly captures "return the same type the array holds." Overloading per element type (first(arr: number[]): number, first(arr: string[]): string, …) is busywork for the maintainer and a worse experience for callers. Prefer generics for parametric relationships.
🎯 Practice task
Write a small overloaded helper. 20-30 minutes.
In your ts-for-qa/src folder, create parse.ts.
Implement the response-parser example with two overloads:
Call it once with each format. console.log the result and the inferred type (hover in VS Code).
Trigger the type checks. After each, read the error and revert:
Pass format: "xml".
Assign the result of the json call to const s: string = parseResponse(..., "json");.
Now try the simpler tool. Rewrite the same idea using a generic:
function fetchAs<T>(url: string, parser: (raw: string) => T): T { /* ... */ }
Call it with (s) => JSON.parse(s) and with (s) => s. Notice how generics give you the same outcome without the overload machinery.
Stretch: add a third overload to parseResponse for format: "csv" that returns string[][]. Walk through the call-site type-narrowing to confirm each format branch returns the right shape. Reflect on whether overloads or a discriminated union object would have read better here — both are valid; the answer depends on how often the function is called.
The chapter wraps up next lesson with callbacks and higher-order functions — typing the patterns that make Cypress and Playwright assertions feel composable.
// tip to track lessons you complete and pick up where you left off across devices.