33.4 Dealing with Untyped Dependencies During Migration
Right, so you’ve started wrapping your own code in types, feeling that sweet, sweet intellisense flow, and then you hit this wall: import someLibrary from 'that-old-untyped-package'. Suddenly, your beautifully typed file is awash in any. It’s like meticulously cleaning your house only to have your roommate drag in a muddy dog. Don’t panic. This is a rite of passage. We’re going to tame that muddy dog, or at least build it a very specific shed so it doesn’t track mud everywhere.
The core problem is simple: a JavaScript dependency has no type information. TypeScript, being the conscientious compiler it is, sees this and basically throws its hands up, declaring the whole imported module and all its exports as any. This is the TypeScript equivalent of a structural engineer seeing a load-bearing column made of gelatin and just writing “probably fine?” on the blueprint. Our job is to provide the blueprint.
The First Line of Defense: @types Packages
Your very first move should always be to check if someone else has already done the hard work. The DefinitelyTyped project is a massive repository of community-maintained type definitions for common untyped libraries. It’s a godsend.
How do you check? You try to install the types. The convention is that types for a package named left-pad would be in an npm package called @types/left-pad.
npm install --save-dev @types/left-pad
If that works, congratulations, you’ve just won the migration lottery. Stop reading this section and go celebrate. TypeScript will automatically detect these types and use them. The library is now typed. The dog is now clean and wearing a little tuxedo.
But what if you get a 404? The package doesn’t exist. Now the real fun begins.
Creating a Declaration File (.d.ts)
When @types/ lets you down, you must become the typemaker. You do this by creating a Declaration File. This is a file whose sole job is to describe the shape of existing JavaScript code to TypeScript. It contains only type information; no actual logic.
You’ll create a file with the .d.ts extension. The easiest place to put it is in a top-level types or @types directory, but you can also just place it next to the file that uses the dependency. I prefer a central types folder to avoid clutter. You then need to ensure TypeScript knows to look for it in your tsconfig.json.
// tsconfig.json
{
"compilerOptions": {
// ... your other options
"typeRoots": ["./node_modules/@types", "./types"] // Look here for types!
}
}
Now, inside types/, you’ll create a file specifically for your dependency, e.g., types/that-old-untyped-package.d.ts.
The Minimalist Approach: declare module
The simplest way to start is to use a declare module block. This tells TypeScript, “Hey, when you see an import from this module, here’s what it looks like.”
Let’s say that-old-untyped-package has a function called configure that takes a string and an optional number, and a version constant.
// types/that-old-untyped-package.d.ts
declare module 'that-old-untyped-package' {
export function configure(setting: string, timeout?: number): void;
export const version: string;
}
Boom. You’ve just typed the library. Any import of configure will now be checked. Pass it a boolean? TypeScript will throw a fit. It’s a huge win for very little effort.
The Realistic Approach: Gradual Typing with any
Here’s the dirty secret they don’t tell you in the fancy guides: you don’t have to fully type the entire library on day one. That’s a recipe for burnout. The goal is to contain the any-ness, not eliminate it instantly.
You can start with a minimal definition that just types the parts you actually use and leaves the rest as any. This is infinitely better than letting any leak everywhere.
// types/that-old-untyped-package.d.ts
declare module 'that-old-untyped-package' {
export function theOneFunctionWeActuallyUse(name: string): string;
// We know this thing exists but we don't use it yet.
// Let's just mark it as `any` for now so we don't break the build.
export const someMysteriousProperty: any;
// A classic move: a bag of mystery properties.
// We know it's an object, but we haven't explored its full shape.
// This is a deliberate, contained `any`.
export const config: any;
}
This is the trench warfare approach. You’re fortifying the specific parts of your codebase you care about right now. Later, as you use more of the library, you can expand this declaration. It’s a living document.
The Nuclear Option: Shutting TypeScript Up
Sometimes, you just need to get the build to pass now so you can keep moving. The library is a fractal of insanity and you don’t have the will to type it. For those moments, you can tell TypeScript to completely disregard the type of a module. This is the “I’ll deal with this later” button.
// types/that-old-untyped-package.d.ts
declare module 'that-old-untyped-package';
Yes, that’s it. No exports. This explicitly marks the entire module as any. It’s technically a step backwards from the default behavior (which implicitly gives you an any), but it’s an explicit and contained step backwards. It silences the --noImplicitAny error for this import and signals to everyone on your team, “We have consciously decided to not deal with this yet.” It’s honesty in code form.
The Pitfall of Over-Enthusiasm
A word of caution: when you start writing these declaration files, you are now on the hook for their accuracy. If you describe a function as taking a string but it actually expects a number, you’ve created a runtime bug that TypeScript will happily bless. Your fancy types are now lying to you. The best practice is to double-check the library’s actual API—its documentation, its source code, or by good old console.logging its outputs—before committing to a type. Assume the library docs are wrong. Assume your memory is wrong. Trust nothing but the actual runtime behavior. It’s a tedious process, but it’s the price we pay for moving from the wild west of JavaScript to the civilized, if sometimes pedantic, society of TypeScript.