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.
#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
pagewhereLocatorwas expected) - A global mutable
testcaseobject 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
.jsand.tsimports without issues (TypeScript resolves.jsimports to.tsfiles automatically) - Validate each phase before moving to the next
#The 5-Phase Migration Plan
We broke the migration into dependency-ordered phases:
| Phase | Scope | Files | Strategy |
|---|---|---|---|
| Phase 0 | Foundation Setup | 0 | Install tooling, create tsconfig.json, keep everything as JS |
| Phase 1 | Support Layer | 17 | Convert foundational utilities that everything depends on |
| Phase 2 | Page Objects | 235 | Convert UI abstraction layer (lots of mechanical work) |
| Phase 3 | Step Definitions | 64 | Convert business logic layer |
| Phase 4 | Specs | 68 | Convert test files themselves |
| Phase 5 | Strict Mode | 400 | Enable 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.
// 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:
// tests/support/types/testcase.types.tsexport interface UserInfo {login: stringid: numberslug?: stringemail?: stringdisplay_name?: string}export interface TestParticipant {apis: NFAPIHelpers | nullinfo?: UserInfo}export interface TestCaseState {userType: 'unauthenticated' | 'advisor' | 'seeker'loggedInUser: string | nulladvisor: TestParticipantseeker: TestParticipantfeatureFlags: 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:
// Before: no types anywhereasync 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 safetyasync 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:
// The type safety killerexport class CookiesBanner {[key: string]: any // 🚨 This suppresses ALL member typingconstructor(page) {this.page = pagethis.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:
import { type Page, type Locator, expect } from '@playwright/test'export class CookiesBanner {readonly page: Pagereadonly banner: Locatorreadonly iUnderstandButton: Locatorconstructor(page: Page) {this.page = pagethis.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
readonlyto prevent accidental mutations - Top-level pages take
Page, components takeLocator - 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:
// The old way: zero typingconst 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:
// New way: full type safetyimport { 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:
// Beforeimport 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')})// Afterimport { 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
noImplicitAnyerrors across the codebase - 368
strictNullCheckserrors - 573 explicit
: anyannotations to remove
The bulk of the noImplicitAny errors were in step definitions:
// This innocent-looking function had IMPLICIT any paramsstatic verifyDownloadStarted = async (page, fileName, downloadPath) => {// TypeScript couldn't infer the types}// Fixed with explicit typingstatic verifyDownloadStarted = async (page: Page,fileName: string,downloadPath: string): Promise<void> => {// Now fully type-safe}
The systematic approach:
- Support layer first — 305 errors across 13 files
- Page objects — mostly done in Phase 2, just cleanup
- Step definitions — the big kahuna, 4,100+ errors across 64 files
- 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:
// Before: everything is anyconst response = await apiHelpers.createListing(listingData)const listingId = response.body.data.listing.id // No type checking 😱// After: full type safetyinterface ListingCreationResponse {listing: {id: numbertitle: stringcategory_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:
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:
// Nightmare for TypeScriptglobal.testcase.advisor.info = userInfoglobal.testcase.seeker.apis = apiHelperglobal.testcase.featureFlags['new_chat_ui'] = true
The solution: proper interface with escape hatch:
interface TestCaseState {userType: 'unauthenticated' | 'advisor' | 'seeker'advisor: TestParticipantseeker: TestParticipantfeatureFlags: 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:
// Confusion: when to use page vs locator?class FileUploadModal {constructor(page) {// Should this be Page or Locator?this.page = pagethis.modal = this.page.getByTestId('upload-modal')}}
We established clear constructor patterns:
// Top-level pages: take Pageclass HomePage {constructor(page: Page) { ... }}// Components/modals: take Locator for scopingclass 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
undefinedto 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.