As you rename files to .ts, you will encounter a recurring set of TypeScript errors. Most of them are variations on the same theme: TypeScript is asking "what is this, exactly?" and the JavaScript code doesn't have an answer. This lesson covers the errors you'll see most often, how to diagnose each one, and the right fix — not just the quickest fix.
Implicit any — TypeScript's most common migration error
When a function parameter has no type annotation and TypeScript cannot infer it from context, it flags the parameter as having an implicit any type. With noImplicitAny: true, this is an error. With noImplicitAny: false (the migration-safe default), it's a warning or silent — but the parameter is still typed as any, which disables all type checking on it.
// ERROR with noImplicitAny: true
function processResponse(data) {
// ^^^^ Parameter 'data' implicitly has an 'any' type
return data.body.users;
}The fix hierarchy, from best to worst:
1. Add an explicit type (always preferred):
interface ApiResponse {
body: { users: User[] };
}
function processResponse(data: ApiResponse): User[] {
return data.body.users;
}2. Use unknown when the shape is genuinely variable:
function processResponse(data: unknown): User[] {
if (
typeof data === 'object' &&
data !== null &&
'body' in data &&
typeof (data as { body: unknown }).body === 'object'
) {
return (data as ApiResponse).body.users;
}
throw new Error('Unexpected API response shape');
}3. Use explicit any as a tracked placeholder:
// TODO: type this properly — see #456
function processResponse(data: any): User[] {
return data.body.users;
}Explicit : any is better than implicit any because it is visible in code review, searchable (grep -rn ": any"), and communicates intent. Implicit any is silent.
The five errors you'll see most
Most common TypeScript migration errors by frequency
Object is possibly undefined
Array.prototype.find(), Map.prototype.get(), and many DOM APIs return T | undefined. Without strictNullChecks, TypeScript lets this through. With it enabled, you must handle the undefined case.
const user = users.find(u => u.id === targetId);
console.log(user.email);
// ERROR: Object is possibly 'undefined'Three valid fixes:
// Fix 1: guard with if
if (user) {
console.log(user.email); // user is User here
}
// Fix 2: throw on not-found (best for test helpers where missing data = test bug)
const user = users.find(u => u.id === targetId);
if (!user) throw new Error(`User ${targetId} not found in fixture`);
console.log(user.email); // narrowed to User
// Fix 3: optional chaining (only when undefined is a valid, silent case)
console.log(user?.email ?? 'unknown');In test code, Fix 2 is almost always right. A missing fixture user means the test setup is wrong — that should fail loudly, not silently return undefined and produce a confusing assertion failure later.
Property does not exist on type
interface TestUser {
id: string;
email: string;
role: string;
}
const user = createTestUser();
console.log(user.eamil);
// ERROR: Property 'eamil' does not exist on type 'TestUser'. Did you mean 'email'?This is TypeScript catching a real bug. Fix the typo. If the property genuinely doesn't exist on the interface yet, add it to the interface rather than working around the error.
Argument of type X is not assignable
function setRole(role: 'admin' | 'member'): void { ... }
const role = 'superadmin';
setRole(role);
// ERROR: Argument of type 'string' is not assignable to parameter of type '"admin" | "member"'This happens when you pass a string where a union literal is expected. TypeScript widens the variable's type to string at declaration. Fix with as const:
const role = 'superadmin' as const; // type: 'superadmin' — but this is still wrong, fix the value
const role = 'admin' as const; // type: 'admin' — correct
setRole(role); // OKOr annotate the variable:
const role: 'admin' | 'member' = 'admin';Type narrowing during migration
Narrowing is TypeScript's technique for refining a broad type to a specific one within a conditional block. You'll use it constantly when handling unknown values from API responses, JSON fixtures, and dynamic imports.
function handleApiResult(result: unknown): string {
// typeof narrowing — for primitives
if (typeof result === 'string') {
return result.toUpperCase(); // result is string here
}
// truthiness narrowing — for null/undefined
if (!result) {
throw new Error('Empty result');
}
// in narrowing — for object property checks
if (typeof result === 'object' && 'message' in result) {
return (result as { message: string }).message;
}
return String(result);
}In Playwright tests, narrowing is common when dealing with page.evaluate() return values:
const count = await page.evaluate(() => document.querySelectorAll('.item').length);
// count is number — evaluate infers the return type from the callbackType assertions — using them safely
A type assertion (as SomeType) tells TypeScript to treat a value as a specific type. It skips the type check entirely. Use it only when you have external evidence that the assertion is correct and the compiler cannot verify it.
// Good use — you know the selector always returns this element type
const input = document.querySelector('#email') as HTMLInputElement;
input.value = 'alice@test.com';
// Good use — JSON.parse result typed against a known schema
const fixture = JSON.parse(fs.readFileSync('users.json', 'utf8')) as { users: TestUser[] };
// Bad use — hiding a real type mismatch
const user = getUser(id) as User; // if getUser returns User | null, this masks nullPrefer narrowing over assertions when the runtime value is genuinely uncertain. Use assertions when you're bridging TypeScript's type system and a well-understood external contract (a DOM API, a JSON file with a known schema, a Playwright evaluate result).
Tracking and reducing your any budget
As migration progresses, track explicit any usage with a simple search:
grep -rn ": any\|as any" src/ --include="*.ts" | wc -lRun this weekly. The number should trend toward zero. Files with a high any count are candidates for the next migration round. A project that's "migrated to TypeScript" but has 400 remaining any annotations has TypeScript's build-step cost without most of its safety.
⚠️ Common mistakes
- Using
as anyto silence every error.const result = something as anyis an ejector seat — it turns TypeScript off for every subsequent operation onresult. Useunknowninstead: it forces you to check before using, which is the point. - Suppressing the null-check error instead of fixing the root cause.
user!.email(non-null assertion) is appropriate only when you have already verified, at the call site, thatuseris non-null. Using it because "I know this can't be null" is usually wrong — it's the same claim the JavaScript developer made before gettingTypeError: Cannot read properties of undefined. - Not reading the full error message. TypeScript error messages are long but precise. The last line often names exactly which field is wrong or which type is mismatched. Most TypeScript beginners read the first line and guess at the fix. Read the whole message.
🎯 Practice task
Take the most complex file you've converted so far.
- Run
npm run type-checkand list all errors. Categorise them: implicit any, possibly undefined, property does not exist, type mismatch. - For every implicit
anyerror: choose between an explicit type,unknownwith narrowing, or explicitanywith a TODO comment. Apply the fix. - For every "possibly undefined" error: decide between a null guard that throws, optional chaining, or widening the return type. Apply the fix that best matches the context (test helper = throw; optional data = optional chaining).
- Run
grep -rn ": any" . --include="*.ts"on your project. Count the explicitanyannotations. Write the number down. This is your baseline. - Stretch: find one place where you used
as SomeTypeand ask whether it's truly safe. Is the assertion correct at runtime in all cases? If not, replace it with a runtime check that throws on unexpected shapes.