Working with Untyped JavaScript Modules

8 min read

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 .js file 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 .js files 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 of allowJs.

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.ts

Then 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 — migrated

Each 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.ts for 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 @types package first — someone may have already done it.
  • Forgetting export {} in .d.ts files that use declare global. Without it, the file is treated as a script (global scope), not a module, and declare global behaves differently. If your .d.ts file uses declare global, add export {} at the bottom to make it a module.
  • Nesting .d.ts files inside node_modules. Never edit files inside node_modules directly — they are overwritten on npm install. Keep all custom declarations in your project's types/ directory.

🎯 Practice task

Find the untyped module your converted files are most likely to encounter.

  1. Run npm run type-check on your project. Find any Could not find a declaration file for module errors.
  2. For each: check if a @types package exists (npm search @types/<name>). If it does, install it.
  3. For an internal .js module that isn't typed yet: write a minimal .d.ts for it in a types/ directory. Declare only the functions and types that your converted files actually import.
  4. Add the typeRoots entry to tsconfig.json so TypeScript finds your custom declarations.
  5. Run npm run type-check. Confirm the module errors are resolved.
  6. Stretch: find a file in your project that uses const x = require('./something'). Convert the require call to an import statement. Run npm run type-check and observe whether TypeScript now infers better types than before.

// tip to track lessons you complete and pick up where you left off across devices.