After the first rename, the majority of your migration work will be typing function signatures. This is where the value concentrates: a typed function propagates type information to every call site, making every caller safer without touching those files. This lesson covers the full range of function signature patterns you'll encounter in a QA codebase — parameters, return types, callbacks, async functions, and overloads.
The anatomy of a typed signature
Every function has three things to type: its parameters, its return value, and — in test code — usually a callback or async pattern.
// Basic signature
function login(email: string, password: string): void { ... }
// With return value
function getAuthToken(email: string, password: string): Promise<string> { ... }
// With optional parameter
function search(query: string, limit?: number): Promise<Result[]> { ... }
// With default value (TypeScript infers the type from the default)
function paginate(page = 1, size = 20): PagedResult { ... }
// page: number, size: number — inferred, no annotation neededParameter types — practical patterns
Object parameters are common in test helpers. Inline object types work for simple cases; interfaces are better for anything used in more than one place.
// Inline type — fine for a one-off parameter
function createTestOrder(options: { userId: string; items: string[]; coupon?: string }): Order { ... }
// Named interface — better for reused shapes
interface OrderOptions {
userId: string;
items: string[];
coupon?: string;
}
function createTestOrder(options: OrderOptions): Order { ... }The interface version is better because it appears in autocomplete tooltips, can be imported by callers that want to build options before calling the function, and can be extended later.
Union types on parameters catch invalid values at compile time — particularly useful for test configuration:
function setUserRole(userId: string, role: "admin" | "member" | "guest"): Promise<void> { ... }
function navigateTo(section: "dashboard" | "settings" | "billing"): void { ... }
function runWith(env: "local" | "staging" | "production"): void { ... }Call setUserRole(id, "superadmin") and you get a compile error, not a 403 at runtime.
Return types — when to be explicit
TypeScript infers most return types correctly. The question is whether to state them explicitly.
Explicit return types for public functions. Any function that callers outside the current file will call should have an explicit return type. It's documentation, and it catches the bug where one code path forgets to return a value:
function getTestUser(role: "admin" | "member"): TestUser {
if (role === "admin") return { ...adminDefaults };
// TypeScript would catch a missing return here if the function declares `: TestUser`
}Inferred return types for internal helpers. Private helper functions inside a file can rely on inference — TypeScript is good at it, and repeating a complex return type is noise:
// TypeScript infers: (input: string) => { value: string; length: number }
function parseInput(input: string) {
return { value: input.trim(), length: input.trim().length };
}Async functions
Async functions in test code nearly always return Promise<void> (side-effect actions) or Promise<SomeType> (data-fetching). State the return type explicitly on async functions — inference works but the explicit form makes it obvious to the reader:
async function loginViaApi(email: string, password: string): Promise<string> {
const response = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
const data = await response.json();
return data.token;
}
async function seedDatabase(users: TestUser[]): Promise<void> {
await Promise.all(users.map((u) => api.post("/users", u)));
}Callback parameters
Higher-order functions — retry wrappers, custom waiters, test lifecycle hooks — take callbacks as parameters. Type the callback:
// Callback that returns void
function retry(fn: () => Promise<void>, attempts: number): Promise<void> { ... }
// Callback with a parameter
function withEachUser(users: TestUser[], fn: (user: TestUser) => Promise<void>): Promise<void> {
return Promise.all(users.map(fn)).then(() => undefined);
}
// Callback that returns a value
function measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
console.time(label);
return fn().finally(() => console.timeEnd(label));
}The generic <T> in measure means it works with any return type without losing type information — the caller gets back Promise<User> if fn returns Promise<User>, not Promise<unknown>.
A real Cypress migration example
// cypress/support/commands.js (before)
Cypress.Commands.add('login', (email, password) => {
cy.request('POST', '/api/auth/login', { email, password })
.then((response) => {
window.localStorage.setItem('token', response.body.token);
});
});
Cypress.Commands.add('seedUser', (role, name) => {
return cy.request('POST', '/api/test/users', { role, name })
.its('body.id');
});// cypress/support/commands.ts (after)
Cypress.Commands.add('login', (email: string, password: string): void => {
cy.request('POST', '/api/auth/login', { email, password })
.then((response) => {
window.localStorage.setItem('token', response.body.token as string);
});
});
Cypress.Commands.add(
'seedUser',
(role: 'admin' | 'member' | 'guest', name: string): Cypress.Chainable<string> => {
return cy.request('POST', '/api/test/users', { role, name }).its('body.id');
}
);The migration added: parameter types that prevent callers from passing the wrong types, a union type on role that catches invalid role names at compile time, and a Chainable<string> return type so callers know what .then() receives.
Migrating a Playwright page object
Page objects are the highest-value migration target. Typed methods give every test that uses the page object full autocomplete and type checking.
// pages/LoginPage.js (before)
class LoginPage {
constructor(page) {
this.page = page;
}
async navigate() {
await this.page.goto('/login');
}
async login(email, password) {
await this.page.fill('#email', email);
await this.page.fill('#password', password);
await this.page.click('button[type=submit]');
}
async getErrorMessage() {
return this.page.textContent('.error-message');
}
}
module.exports = { LoginPage };// pages/LoginPage.ts (after)
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async navigate(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.page.fill('#email', email);
await this.page.fill('#password', password);
await this.page.click('button[type=submit]');
}
async getErrorMessage(): Promise<string | null> {
return this.page.textContent('.error-message');
}
}Notice getErrorMessage returns Promise<string | null> — textContent can return null when the element doesn't exist. The JavaScript version hid this. The TypeScript version surfaces it so callers know they must handle null.
⚠️ Common mistakes
- Annotating every local variable.
const token: string = response.body.token— the: stringis redundant because TypeScript infers it from the assignment. Annotate parameters and return types; let inference handle locals. - Using
Functionas a parameter type.callback: Functiontells TypeScript almost nothing — it's nearly as bad asany. Write the specific function signature:callback: (user: User) => void. - Forgetting
Promise<void>on async functions that don't return a value. An async function with no return statement returnsPromise<void>. Without an explicit return type, TypeScript infers this correctly — but stating it explicitly is better practice for public methods.
🎯 Practice task
Take the second file you're migrating (the one after your first rename).
- Before renaming, write down every function in the file. For each, predict: what types should the parameters be, and what should the return type be?
- Rename the file to
.tswithgit mv. - For each function, add parameter types and an explicit return type. Use interfaces for any object shapes with more than two properties.
- Find any callbacks the file passes to other functions. Type them:
() => void,(user: TestUser) => Promise<void>, etc. - Run
npm run type-check. Fix remaining errors. - Stretch: find a function in the file that returns a different type depending on a condition (for example, a string in one branch and a number in another). What does TypeScript infer as the return type? Is the union type accurate, or does it reveal a design problem in the function?