As you convert files to TypeScript, some of your imports will point at JavaScript files — internal helpers you haven't migrated yet, legacy utilities that predate the project, or third-party npm packages with no type definitions. TypeScript needs to know the shape of everything it compiles. This lesson covers the options for handling untyped modules, ranked by how much type safety each provides.
What TypeScript sees when it imports an untyped file
When a .ts file imports from a .js file that has no type declarations, the behaviour depends on your tsconfig.json:
allowJs: true, checkJs: false— the.jsfile is included in the build, TypeScript infers basic types from the file's code, and the import works without error. This is the migration-safe default.allowJs: false— TypeScript ignores.jsfiles entirely. Importing one is an error:Could not find a declaration file for module './helpers'.- External npm package with no types — TypeScript reports
Could not find a declaration file for module 'some-package'regardless ofallowJs.
With allowJs: true, internal .js imports usually just work during migration. The problem is external packages with no @types companion — those need explicit handling.
The four options, best to worst
Option 1: Convert the module to TypeScript (best)
If the untyped module is code you own, converting it is the most durable solution. It gives you full type safety and eliminates the need for workarounds.
git mv src/helpers/auth-helper.js src/helpers/auth-helper.tsThen type it as covered in Lessons 1 and 2 of this chapter. Every importer immediately gets type information.
Option 2: Write a minimal .d.ts declaration
When you can't convert a module immediately — it's too large, too complex, or it's a third-party package — write a .d.ts file that describes only the parts your code actually uses.
Create a types/ directory at the project root:
// types/legacy-auth.d.ts
declare module '../helpers/legacy-auth' {
export function login(email: string, password: string): Promise<{ token: string }>;
export function logout(): Promise<void>;
export function getCurrentUser(): { id: string; email: string } | null;
}For a third-party npm package:
// types/custom-reporter.d.ts
declare module 'our-custom-reporter' {
export interface ReportOptions {
outputPath: string;
format: 'html' | 'json';
includeScreenshots?: boolean;
}
export function generateReport(results: unknown[], options: ReportOptions): Promise<void>;
}Tell TypeScript about your custom types directory:
// tsconfig.json
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"]
}
}Type only what you use — not the entire library. A minimal declaration that covers your ten actual usages is far better than a complete declaration you'll never finish writing.
Option 3: Declare the module as untyped (safe but imprecise)
When a module is too complex to type right now and you need to unblock compilation:
// types/untyped-modules.d.ts
declare module 'legacy-report-lib';
declare module '../helpers/old-format-helper';An empty declare module tells TypeScript "this module exists" and types all imports from it as any. You lose type safety for those imports, but the build succeeds and you can track the debt with a TODO.
Add a comment so the intent is visible in code review:
// TODO: type these properly — tracked in #789
// legacy-report-lib has no @types package and is pending replacement in Q3
declare module 'legacy-report-lib';Option 4: Per-line suppression (last resort)
When a single import or call causes an error and you can't address the root cause immediately:
// @ts-expect-error — legacy-auth has no types yet, tracked in #456
import { login } from './legacy-auth';@ts-expect-error is better than @ts-ignore because it fails compilation if the error is ever resolved — reminding you to remove the suppression. Always add a comment explaining why.
Writing a useful minimal .d.ts
The most common mistake when writing declaration files is trying to type everything. The better approach: look at your actual import statements and type only those exports.
// What your code actually uses:
import { formatCurrency, parseDate } from 'internal-format-lib';
import type { FormatOptions } from 'internal-format-lib';
// The matching minimal .d.ts:
declare module 'internal-format-lib' {
export interface FormatOptions {
locale?: string;
currency?: string;
}
export function formatCurrency(amount: number, options?: FormatOptions): string;
export function parseDate(input: string): Date | null;
// Everything else in the library: not declared, not imported, not our problem
}This file takes fifteen minutes to write and gives you type checking on every usage in your codebase.
Handling require() imports in migrated files
Legacy JavaScript files that haven't been converted yet often use require(). When a converted .ts file imports from them, TypeScript handles the import — but the call site often uses require syntax that doesn't work well in TypeScript:
// Problematic — TypeScript types the result as `any`
const { login, logout } = require('./auth-helper');
// Better — use import syntax, TypeScript resolves the shape
import { login, logout } from './auth-helper';If auth-helper.js is still JavaScript but has allowJs: true, the import syntax works and TypeScript infers types from the JS file. If inference isn't good enough, fall back to a .d.ts file.
Practical example: a mid-migration Playwright project
Here's a realistic state for a Playwright project 50% through migration, with three different kinds of untyped dependencies:
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test'; // typed — built in
import { LoginPage } from '../pages/LoginPage'; // typed — already migrated
import { formatCurrency } from '../utils/formatters'; // typed via minimal .d.ts
// @ts-expect-error — legacy cart helper, migration planned for sprint 4
import { calculateCartTotal } from '../legacy/cart'; // untyped — suppressed
import type { CheckoutFixture } from './fixtures'; // typed — migratedEach import represents a different strategy. The comment on the suppressed import makes it obvious it's tracked and temporary.
⚠️ Common mistakes
- Writing a complete
.d.tsfor a large library. A complete type declaration for a 200-function library takes days and is likely wrong in subtle ways. Scope it to what you use. If the library is important enough to fully type, look for a community@typespackage first — someone may have already done it. - Forgetting
export {}in.d.tsfiles that usedeclare global. Without it, the file is treated as a script (global scope), not a module, anddeclare globalbehaves differently. If your.d.tsfile usesdeclare global, addexport {}at the bottom to make it a module. - Nesting
.d.tsfiles insidenode_modules. Never edit files insidenode_modulesdirectly — they are overwritten onnpm install. Keep all custom declarations in your project'stypes/directory.
🎯 Practice task
Find the untyped module your converted files are most likely to encounter.
- Run
npm run type-checkon your project. Find anyCould not find a declaration file for moduleerrors. - For each: check if a
@typespackage exists (npm search @types/<name>). If it does, install it. - For an internal
.jsmodule that isn't typed yet: write a minimal.d.tsfor it in atypes/directory. Declare only the functions and types that your converted files actually import. - Add the
typeRootsentry totsconfig.jsonso TypeScript finds your custom declarations. - Run
npm run type-check. Confirm the module errors are resolved. - Stretch: find a file in your project that uses
const x = require('./something'). Convert therequirecall to animportstatement. Runnpm run type-checkand observe whether TypeScript now infers better types than before.