Migrating a 400-File Playwright Test Suite from JavaScript to TypeScript: A Battle-Tested Strategy

How we systematically migrated 400+ Playwright test files from JavaScript to TypeScript without breaking anything — tackling 6,260 type errors, API helpers, and strict mode challenges.

12 min read

#The Problem: Zero Type Safety in a Critical Test Suite

Our Playwright E2E test suite had grown to 400 JavaScript files: 68 specs, 73 step definitions, 235 page objects, and 18 support files. That's a lot of code protecting our users from bugs—but the test code itself had zero type safety.

Every day, we dealt with:

  • No autocomplete for page object locators or method parameters
  • Silent bugs from wrong parameter types (passing page where Locator was expected)
  • A global mutable testcase object with properties added dynamically at runtime
  • API helper methods (2,600+ lines) returning untyped objects
  • Refactoring anxiety — change a method signature and hope you caught all the callers

The kicker? Tests would pass locally but fail in CI because of type mismatches we couldn't catch during development.

We needed TypeScript. But migrating 400 files in one shot? That's a recipe for disaster.

#Strategy: Incremental Migration with allowJs: true

After evaluating options, we chose an incremental approach. No big-bang rewrite. No developer productivity freeze. Existing JavaScript and new TypeScript would coexist throughout the migration.

The secret sauce: TypeScript's allowJs: true setting. This lets you:

  • Convert files one at a time over weeks or months
  • Keep all tests passing after each individual file conversion
  • Mix .js and .ts imports without issues (TypeScript resolves .js imports to .ts files automatically)
  • Validate each phase before moving to the next

#The 5-Phase Migration Plan

We broke the migration into dependency-ordered phases:

PhaseScopeFilesStrategy
Phase 0Foundation Setup0Install tooling, create tsconfig.json, keep everything as JS
Phase 1Support Layer17Convert foundational utilities that everything depends on
Phase 2Page Objects235Convert UI abstraction layer (lots of mechanical work)
Phase 3Step Definitions64Convert business logic layer
Phase 4Specs68Convert test files themselves
Phase 5Strict Mode400Enable full type strictness (the real challenge)

Each phase had a clear scope, verification criteria, and rollback plan. The dependency order meant we never broke import chains.

#Phase 0: Foundation Without Disruption

First, we set up TypeScript without converting any actual code. The goal: install tooling and prove that nothing breaks.

jsonc
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext", // Critical for ESM projects
"moduleResolution": "NodeNext",
"noEmit": true, // Playwright handles transpilation
"allowJs": true, // Coexistence mode
"checkJs": false, // Don't type-check JS files yet
"strict": false, // Enable strict mode in Phase 5
"noImplicitAny": false, // The big boss for Phase 5
"baseUrl": ".",
"paths": {
"@support/*": ["tests/support/*"],
"@pages/*": ["tests/pages/*"],
"@defs/*": ["tests/step_definitions/*"],
},
},
"include": ["tests/**/*.ts", "tests/**/*.js", "playwright.config.ts"],
"exclude": ["node_modules", "test-results", "playwright-report"],
}

Two critical decisions:

module: "NodeNext" was essential for our ESM project. With "type": "module" in package.json, this was the only setting that properly handled import extensions.

Import extensions stay as .js — when you rename helper.js to helper.ts, all existing from './helper.js' imports continue working. TypeScript automatically resolves .js extensions to .ts files. Zero import updates required.

We also created a comprehensive type definitions directory:

typescript
// tests/support/types/testcase.types.ts
export interface UserInfo {
login: string
id: number
slug?: string
email?: string
display_name?: string
}
export interface TestParticipant {
apis: NFAPIHelpers | null
info?: UserInfo
}
export interface TestCaseState {
userType: 'unauthenticated' | 'advisor' | 'seeker'
loggedInUser: string | null
advisor: TestParticipant
seeker: TestParticipant
featureFlags: Record<string, boolean>
// Critical escape hatch during migration
[key: string]: unknown
}

That [key: string]: unknown escape hatch was crucial — it let us gradually add explicit types without breaking existing dynamic property access.

#Phase 1: Support Layer — The Foundation

Next, we tackled the 17 support files that everything else depends on. API helpers, test fixtures, utilities — the works.

The big challenge: our API helpers were massive. nf_api_helpers.ts alone was 1,560 lines with methods like:

typescript
// Before: no types anywhere
async postListingBid(listingId, bidAmount, notes, login) {
const response = await this.makeRequest(
'POST',
`/api/advisor/bids`,
{ body: JSON.stringify({ listing_id: listingId, bid_amount: bidAmount, notes }) },
`Post bid ${bidAmount} for listing ${listingId}`
);
return response;
}
// After: full type safety
async postListingBid(
listingId: number,
bidAmount: number,
notes: string,
login: string
): Promise<APIResponse<BidCreationResponse>> {
const response = await this.makeRequest<BidCreationResponse>(
'POST',
`/api/advisor/bids`,
{
body: JSON.stringify({
listing_id: listingId,
bid_amount: bidAmount,
notes
})
},
`Post bid ${bidAmount} for listing ${listingId}`
);
return response;
}

We typed every method signature first, then tackled method bodies later. This bottom-up approach meant page objects could immediately start getting better autocomplete.

#Phase 2: Page Objects — The Mechanical Marathon

235 page object files. Every single one had this pattern:

typescript
// The type safety killer
export class CookiesBanner {
[key: string]: any // 🚨 This suppresses ALL member typing
constructor(page) {
this.page = page
this.banner = this.page.getByTestId('cookies-banner')
this.iUnderstandButton = this.banner.getByTestId('dismiss-button')
}
}

That [key: string]: any index signature was devastating for type safety. It meant any this.xyz assignment in the constructor was accepted without questioning what xyz should be.

The fix was mechanical but essential:

typescript
import { type Page, type Locator, expect } from '@playwright/test'
export class CookiesBanner {
readonly page: Page
readonly banner: Locator
readonly iUnderstandButton: Locator
constructor(page: Page) {
this.page = page
this.banner = this.page.getByTestId('cookies-banner')
this.iUnderstandButton = this.banner.getByTestId('dismiss-button')
}
async expectToBeVisible(): Promise<void> {
await expect(this.banner).toBeVisible()
}
}

Key decisions:

  • Everything becomes readonly to prevent accidental mutations
  • Top-level pages take Page, components take Locator
  • Add proper expect* methods instead of exposing raw locators

We processed 235 files in batches of 15-25, starting with the smallest directories for quick wins.

#Phase 3: Step Definitions — Breaking the Record<string, any> Pattern

Our step definitions all followed this pattern:

typescript
// The old way: zero typing
const defs: Record<string, any> = {}
defs.verifyPageAppears = async (page, expectedTitle) => {
return test.step('Verify page appears', async () => {
await expect(page).toHaveTitle(expectedTitle)
})
}
export default defs

That Record<string, any> pattern completely defeated TypeScript's type checking. Method signatures were invisible to callers.

We migrated to static class pattern:

typescript
// New way: full type safety
import { type Page } from '@playwright/test'
import { test } from '../../support/baseTest.js'
export class FilePlayerDefs {
static verifyPageAppears = async (page: Page, expectedTitle: string): Promise<void> => {
return test.step('Verify File Player page appears', async () => {
await expect(page).toHaveTitle(expectedTitle)
})
}
static downloadSingleFile = async (page: Page, fileName: string): Promise<void> => {
// Implementation with full type safety
}
}

Why static classes?

  • Perfect TypeScript integration — autocomplete and type checking work flawlessly
  • Clear method signatures visible to all callers
  • Easy to import with destructuring: import { FilePlayerDefs } from './FilePlayer.defs.js'
  • Better than namespace exports for our use case

The conversion was time-consuming but mostly mechanical. Each of the 64 step definition files needed individual attention to properly type all method parameters.

#Phase 4: Specs — The Easy Victory

By the time we reached specs (the actual test files), everything else was already typed. Converting specs was surprisingly straightforward:

typescript
// Before
import common from '../../step_definitions/nf/common.defs.js'
import filePlayerDefs from '../../step_definitions/nfplus/FilePlayer.defs.js'
test('should download single file', async ({ page }) => {
await common.login(page, 'advisor')
await filePlayerDefs.navigateToFilePlayer(page)
await filePlayerDefs.downloadSingleFile(page, 'example.pdf')
})
// After
import { NfCommonDefs } from '../../step_definitions/nf/common.defs.js'
import { FilePlayerDefs } from '../../step_definitions/nfplus/FilePlayer.defs.js'
test('should download single file', async ({ page }) => {
await NfCommonDefs.login(page, 'advisor')
await FilePlayerDefs.navigateToFilePlayer(page)
await FilePlayerDefs.downloadSingleFile(page, 'example.pdf')
})

The specs layer mostly orchestrates—it doesn't contain complex type logic. With proper imports and method calls, most conversion was just updating import statements.

#Phase 5: Strict Mode — The Real Battle

Phases 0-4 gave us zero TypeScript errors under loose compiler settings. But the real type safety comes from strict mode. We flipped the flags and faced the reality:

  • 6,260 noImplicitAny errors across the codebase
  • 368 strictNullChecks errors
  • 573 explicit : any annotations to remove

The bulk of the noImplicitAny errors were in step definitions:

typescript
// This innocent-looking function had IMPLICIT any params
static verifyDownloadStarted = async (page, fileName, downloadPath) => {
// TypeScript couldn't infer the types
}
// Fixed with explicit typing
static verifyDownloadStarted = async (
page: Page,
fileName: string,
downloadPath: string
): Promise<void> => {
// Now fully type-safe
}

The systematic approach:

  1. Support layer first — 305 errors across 13 files
  2. Page objects — mostly done in Phase 2, just cleanup
  3. Step definitions — the big kahuna, 4,100+ errors across 64 files
  4. Specs — minimal errors since they mostly call typed methods

We tackled files in dependency order and error count order — fix the foundational files with the most errors first.

#The Biggest Challenges (And How We Solved Them)

#Challenge 1: API Response Typing

Our API helpers returned giant untyped objects:

typescript
// Before: everything is any
const response = await apiHelpers.createListing(listingData)
const listingId = response.body.data.listing.id // No type checking 😱
// After: full type safety
interface ListingCreationResponse {
listing: {
id: number
title: string
category_id: number
// ... 50+ more properties
}
}
const response = await apiHelpers.createListing(listingData)
const listingId = response.body.data.listing.id // ✅ Typed and validated!

We created separate interface files for each major API domain, then used generics extensively:

typescript
class APIHelper {
async makeRequest<T = unknown>(
method: string,
url: string,
options?: RequestOptions
): Promise<APIResponse<T>> {
// Implementation
}
}

#Challenge 2: The Global testcase Object

We had a global mutable object that accumulated state during tests:

typescript
// Nightmare for TypeScript
global.testcase.advisor.info = userInfo
global.testcase.seeker.apis = apiHelper
global.testcase.featureFlags['new_chat_ui'] = true

The solution: proper interface with escape hatch:

typescript
interface TestCaseState {
userType: 'unauthenticated' | 'advisor' | 'seeker'
advisor: TestParticipant
seeker: TestParticipant
featureFlags: Record<string, boolean>
// Critical: allow dynamic properties during migration
[key: string]: unknown
}
declare global {
var testcase: TestCaseState
}

The index signature let existing dynamic code work while we gradually made properties explicit.

#Challenge 3: Playwright's Locator vs Element Confusion

Our page objects mixed Page and Locator concepts inconsistently:

typescript
// Confusion: when to use page vs locator?
class FileUploadModal {
constructor(page) {
// Should this be Page or Locator?
this.page = page
this.modal = this.page.getByTestId('upload-modal')
}
}

We established clear constructor patterns:

typescript
// Top-level pages: take Page
class HomePage {
constructor(page: Page) { ... }
}
// Components/modals: take Locator for scoping
class FileUploadModal {
constructor(parentLocator: Locator) {
this.modal = parentLocator.getByTestId('upload-modal');
}
}

#Lessons Learned: What Worked (And What Didn't)

#✅ What Worked

Dependency-ordered migration: Converting support → pages → step defs → specs meant we never broke import chains.

Batch processing: Converting 15-25 files at a time gave manageable chunks with clear progress milestones.

Error count prioritization: Fixing files with the most errors first gave maximum impact per unit effort.

Static class pattern for step defs: Much better than namespace exports for our use case. Perfect IDE support.

Pragmatic escape hatches: [key: string]: unknown and : any annotations let us make progress without getting stuck on edge cases.

#❌ What Was Harder Than Expected

The sheer scale of mechanical work: 235 page object files with identical patterns took weeks to convert properly.

API response typing: Creating accurate interfaces for 50+ API endpoints was time-consuming detective work.

Third-party library types: Some of our dependencies had poor or missing TypeScript definitions.

#The Results: Was It Worth It?

Six months later, the answer is emphatically yes.

Developer Experience:

  • Autocomplete works everywhere — no more guessing parameter types
  • Refactoring is fearless — the compiler catches breaking changes
  • Onboarding new team members is faster with self-documenting code

Bug Prevention:

  • Caught 12 real parameter type bugs during strict mode conversion
  • Prevented deployment of a critical test that was passing undefined to an API expecting a string
  • Zero runtime type errors in tests since migration completion

Maintenance:

  • Adding new page objects is faster with established patterns
  • API changes surface immediately across all test files
  • Code reviews focus on logic, not "did you pass the right parameter type?"

#If You're Planning a Similar Migration

Based on our experience, here's what I'd recommend:

#Start Early and Small

Don't wait until you have 400 files. The mechanical work scales linearly, but the coordination overhead scales quadratically.

#Invest in Good Types Up Front

Spend time creating comprehensive interfaces for your API responses and core domain objects. The investment pays dividends across hundreds of files.

#Use Escape Hatches Strategically

Don't be afraid of : any annotations and index signatures during migration. Perfect type safety is the destination, not the journey.

#Batch by Dependencies

Always convert dependencies before dependents. We never had to touch the same file twice.

#Automate What You Can

We wrote scripts to generate feature maps and function registries. Find the mechanical patterns and script them.

The migration took us about 4 months of part-time effort. Could we have done it faster? Absolutely. But we prioritized keeping all tests passing and maintaining development velocity throughout the process.

TypeScript isn't just a different syntax—it's a different way of thinking about code reliability. Our test suite is now a pleasure to work with, and I can't imagine going back to untyped JavaScript for a codebase this large.


Want to discuss TypeScript migration strategies or share your own war stories? Find me on Twitter/X or LinkedIn — I'd love to hear about your experience.

Subscribe to my space 🚀

Stay updated on my Blogs about Automation Test, Swift & iOS, Software Engineering, and book reviews.

100% free. Unsubscribe at any time.