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 --versionCreate 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.tscommands.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.tsConvert 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 runThe 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 require→import 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;
}