Guided Walkthrough — Step-by-Step Migration

12 min read

This walkthrough takes you through the Cypress capstone migration in seven stages. Each stage produces a working, committable state — CI stays green, tests keep running, and you can stop between stages without leaving the project broken. Follow along with your own project; adapt where your codebase differs from the examples.

Stage 1: TypeScript foundation (Day 1 morning)

Goal: TypeScript is installed, both tsconfig files exist, npm run type-check passes on the unchanged JavaScript codebase.

Install and verify:

npm install --save-dev typescript @types/node
npx tsc --version

Create tsconfig.json at the project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noImplicitAny": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["**/*.ts", "**/*.js"],
  "exclude": ["node_modules", "cypress/screenshots", "cypress/videos", "dist"]
}

Create cypress/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "types": ["cypress", "node"],
    "isolatedModules": false
  },
  "include": ["**/*.ts", "**/*.js"],
  "exclude": []
}

Add the type-check script:

{
  "scripts": {
    "type-check": "tsc --noEmit"
  }
}

Run npm run type-check. It must exit with zero errors before you write a single line of TypeScript. If you see errors, check your exclude list — something unexpected is being included.

Commit: "Add TypeScript foundation: tsconfig files and type-check script"

Stage 2: Define the types layer (Day 1 afternoon)

Goal: all shared interfaces defined, custom commands declared, environment variables typed. No file renames yet.

Create cypress/types/index.ts:

export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'member' | 'guest';
  createdAt: string;
}
 
export interface Product {
  id: string;
  name: string;
  price: number;
  sku: string;
  inStock: boolean;
}
 
export interface CartItem {
  productId: string;
  quantity: number;
  unitPrice: number;
}
 
export interface Order {
  id: string;
  userId: string;
  items: CartItem[];
  total: number;
  status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
}

Create cypress/types/cypress.d.ts with declarations for all six custom commands. Look at commands.js and declare each one:

declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      loginAsAdmin(): Chainable<void>;
      seedUser(role: 'admin' | 'member' | 'guest'): Chainable<string>;
      addToCart(productId: string, quantity?: number): Chainable<void>;
      clearCart(): Chainable<void>;
      waitForApi(alias: string): Chainable<Cypress.Response<unknown>>;
    }
  }
}
 
export {};

Create cypress/types/env.d.ts:

declare namespace NodeJS {
  interface ProcessEnv {
    BASE_URL: string;
    API_KEY: string;
    TEST_USER_EMAIL: string;
    TEST_USER_PASSWORD: string;
    CI?: string;
  }
}

Run npm run type-check. Still zero errors — these files are declarations only, they add no runtime code.

Commit: "Add type definitions: domain interfaces, Cypress command declarations, env types"

Stage 3: Migrate page objects (Days 2–3)

Goal: all three page objects converted to TypeScript. Migrate one at a time, run the tests after each.

LoginPage.js is the best starting point — it's the simplest and used by multiple test files. Rename and type:

git mv cypress/pages/LoginPage.js cypress/pages/LoginPage.ts
// cypress/pages/LoginPage.ts
export class LoginPage {
  visit(): void {
    cy.visit('/login');
  }
 
  fillEmail(email: string): void {
    cy.get('[data-testid="email"]').type(email);
  }
 
  fillPassword(password: string): void {
    cy.get('[data-testid="password"]').type(password);
  }
 
  submit(): void {
    cy.get('[data-testid="submit"]').click();
  }
 
  login(email: string, password: string): void {
    this.fillEmail(email);
    this.fillPassword(password);
    this.submit();
  }
 
  getErrorMessage(): Cypress.Chainable<string | null> {
    return cy.get('[data-testid="error"]').invoke('text');
  }
}

Run npm run type-check then npx cypress run. The test files that import LoginPage still use require() — with allowJs: true, they can import from the new .ts file without changes. Tests should pass unchanged.

Commit: "Migrate LoginPage.js to TypeScript"

Repeat for CheckoutPage and ProductPage. After each rename, run npm run type-check and npx cypress run. If tests break, fix the type errors before moving on.

Stage 4: Migrate support files (Day 4)

Goal: commands.ts and e2e.ts migrated. Custom commands implement the types declared in Stage 2.

git mv cypress/support/commands.js cypress/support/commands.ts
git mv cypress/support/e2e.js cypress/support/e2e.ts

commands.ts — the implementations must match the signatures declared in cypress.d.ts:

// cypress/support/commands.ts
 
Cypress.Commands.add('login', (email: string, password: string): void => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password },
  }).then((response) => {
    cy.setCookie('auth_token', (response.body as { token: string }).token);
  });
});
 
Cypress.Commands.add('loginAsAdmin', (): void => {
  cy.login(Cypress.env('TEST_USER_EMAIL') as string, Cypress.env('TEST_USER_PASSWORD') as string);
});
 
Cypress.Commands.add('seedUser', (role: 'admin' | 'member' | 'guest'): Cypress.Chainable<string> => {
  return cy.request('POST', '/api/test/seed/user', { role }).its('body.id');
});
 
Cypress.Commands.add('addToCart', (productId: string, quantity = 1): void => {
  cy.request('POST', '/api/cart/items', { productId, quantity });
});
 
Cypress.Commands.add('clearCart', (): void => {
  cy.request('DELETE', '/api/cart');
});
 
Cypress.Commands.add('waitForApi', (alias: string): Cypress.Chainable<Cypress.Response<unknown>> => {
  return cy.wait(`@${alias}`);
});

Run npm run type-check. The signatures in commands.ts are checked against the declarations in cypress.d.ts — any mismatch is a compile error, not a runtime surprise.

Commit: "Migrate support files: commands.ts and e2e.ts"

Stage 5: Migrate test files (Days 5–7)

Goal: all five test files converted to .cy.ts. Migrate one at a time, run after each.

Start with the simplest test — login.cy.js:

git mv cypress/e2e/login.cy.js cypress/e2e/login.cy.ts

Convert require to import, add fixture types:

// cypress/e2e/login.cy.ts
import { LoginPage } from '../pages/LoginPage';
import type { User } from '../types';
 
const loginPage = new LoginPage();
 
describe('Login', () => {
  beforeEach(() => {
    loginPage.visit();
  });
 
  it('logs in with valid credentials from fixture', () => {
    cy.fixture<User>('users/member.json').then((user) => {
      loginPage.login(user.email, 'password123');
      cy.url().should('include', '/dashboard');
    });
  });
 
  it('shows error with invalid password', () => {
    loginPage.login('alice@test.com', 'wrongpassword');
    loginPage.getErrorMessage().should('contain', 'Invalid credentials');
  });
 
  it('uses the login custom command', () => {
    cy.login('alice@test.com', 'password123');
    cy.url().should('include', '/dashboard');
  });
});

Run npx cypress run --spec 'cypress/e2e/login.cy.ts' immediately. Fix any failures before renaming the next file.

Continue with profile.cy.js, search.cy.js, admin.cy.js, and finally checkout.cy.js (the most complex). Commit each conversion separately.

Step 1 of 7

Stage 1: Foundation

tsconfig.json, cypress/tsconfig.json, npm run type-check. Passes with zero errors on unchanged JS project.

Stage 6: Configuration and cleanup (Day 8)

Goal: cypress.config.ts replaces cypress.config.js. allowJs removed. CI updated.

git mv cypress.config.js cypress.config.ts
// cypress.config.ts
import { defineConfig } from 'cypress';
 
export default defineConfig({
  e2e: {
    baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.ts',
  },
  env: {
    TEST_USER_EMAIL: process.env.TEST_USER_EMAIL,
    TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD,
  },
});

Now that all files are .ts, remove allowJs from tsconfig.json. Change "include" to ["**/*.ts"] only. Run npm run type-check. If any errors appear, they're from files you hadn't noticed were still JavaScript — rename them now.

Update CI (GitHub Actions or similar):

- name: Type check
  run: npm run type-check
 
- name: Run Cypress tests
  run: npx cypress run

The type check runs before the test run, so type errors fail fast without spending time on browser startup.

Write MIGRATION.md — a short document covering: what TypeScript version is installed, which strict flags are enabled and why, how to create a new test file (.ts only), and where the custom command type declarations live.

Commit: "Complete TypeScript migration: config, CI, and documentation"

Stage 7: Tighten strictness (Days 9–10)

Goal: strict: true in tsconfig.json, zero errors.

Enable flags one at a time, fixing errors after each:

Round 1 — noImplicitAny: true: Run npm run type-check. Every untyped parameter in every converted file will produce an error. Fix them by adding types — not by suppressing. Track the count: it should drop from maybe 30–50 errors to zero over a morning of work.

Commit: "Enable noImplicitAny: true — all parameters explicitly typed"

Round 2 — strictNullChecks: true: Run npm run type-check. Every .find(), .get(), and DOM query now returns T | undefined or T | null. For test helpers, use the throw-on-not-found pattern. For optional UI elements, use optional chaining.

Commit: "Enable strictNullChecks: true — null safety enforced"

Round 3 — strict: true: Remove the individual flags and replace with "strict": true. Run npm run type-check. The remaining errors are usually from strictPropertyInitialization (class properties not initialised in constructors) and noImplicitThis. Fix them.

Commit: "Enable strict: true — full TypeScript strict mode"

At this point, run grep -rn ": any\|as any" cypress/ --include="*.ts" and count. The target is zero. Each remaining any is a known gap in type coverage — track them as technical debt if you can't eliminate them immediately.

Commit: "Capstone complete: fully typed Cypress project with strict mode"

What to do if you get stuck

Error in a file you haven't reached yet: use // @ts-expect-error — not yet migrated with a comment. Don't fix errors out of sequence — it breaks the rhythm of the migration.

Complex type error you don't understand: paste the full error message (all lines, not just the first) into your search engine or AI assistant. TypeScript errors are verbose because they're precise — the last line usually tells you exactly what's wrong.

Tests fail after a rename: check whether the failure is a type error (found by npm run type-check) or a logic error (found by cypress run). Type errors are your responsibility; logic errors that appeared after migration likely mean a CommonJS-to-ESM conversion changed behaviour (check your requireimport conversions).

Stuck on any types in the custom commands: the Cypress.env() API returns any by default. A typed wrapper helps:

function env(key: keyof NodeJS.ProcessEnv): string {
  const value = Cypress.env(key) as string | undefined;
  if (!value) throw new Error(`Missing Cypress env: ${key}`);
  return value;
}

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