Adding Types to Function Signatures

9 min read

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 needed

Parameter 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 : string is redundant because TypeScript infers it from the assignment. Annotate parameters and return types; let inference handle locals.
  • Using Function as a parameter type. callback: Function tells TypeScript almost nothing — it's nearly as bad as any. 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 returns Promise<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).

  1. 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?
  2. Rename the file to .ts with git mv.
  3. For each function, add parameter types and an explicit return type. Use interfaces for any object shapes with more than two properties.
  4. Find any callbacks the file passes to other functions. Type them: () => void, (user: TestUser) => Promise<void>, etc.
  5. Run npm run type-check. Fix remaining errors.
  6. 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?

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