Type Untyped Packages in TypeScript
Learn how to add TypeScript types to npm packages that ship without them. Create declaration files for untyped JavaScript libraries so you get autocomplete and type safety.
To type untyped packages in TypeScript, you write your own declaration file, since the compiler has no type information for a package written in plain JavaScript. Without one, installing that package gives you a compiler error and no autocomplete or type checking.
The fix is to create your own declaration file. This article shows you how to add TypeScript types to any JavaScript package, from a quick one-line shim to a full type definition.
Step 1: Check If Types Already Exist
Before writing anything, check whether types are already available:
- Run
npm install --save-dev @types/package-name. Many popular packages have community-maintained types on DefinitelyTyped. - Look inside the package's node_modules folder for declaration files. Some packages bundle their own types.
- Check the package's package.json for a types or typings field.
If the package has no community types, npm rejects the install with a 404 instead of adding a dependency. That error is your signal to write the declaration file yourself.
npm error 404 '@types/package-name@*' is not in this registry.This error means no community types exist. Time to create them.
Step 2: The Quick Shim
The fastest way to silence the compiler error is a minimal shim. Create a declaration file that tells TypeScript the module exists, but treat everything as any:
// types/untyped-pkg.d.ts
declare module "untyped-pkg";With this one line, TypeScript stops complaining because the module now exists as far as the compiler is concerned. You can import the package normally, though every value coming from it is typed as any.
import something from "untyped-pkg";
// something is typed as anyThis shim is a temporary placeholder, not a permanent solution. You get no type checking, but at least the project compiles. Replace it with real types as soon as you understand the API you are using.
Step 3: Set Up the Declaration File Folder
Create a dedicated folder for your custom type declarations:
your-project/
src/
types/
untyped-pkg.d.ts
another-pkg.d.ts
tsconfig.jsonThe types folder sits next to src, at the project root, and holds one declaration file per untyped package. Then tell TypeScript about the folder in your tsconfig.json.
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["src", "types"]
}The typeRoots option tells TypeScript where to look for type packages. The include option ensures your declaration files are part of the compilation. If you do not want to touch typeRoots, simply make sure the folder is under an already-included directory like src.
Step 4: Add Real Types for the Parts You Use
Start with the smallest useful declaration. You do not need to type the entire package. Type only the exports you actually import.
Here is an imaginary untyped package called color-utils with this API:
// color-utils (JavaScript you cannot edit)
function hexToRgb(hex) {
// Parses "#ff0000" into { r: 255, g: 0, b: 0 }
}
function rgbToHex(r, g, b) {
// Converts (255, 0, 0) to "#ff0000"
}
module.exports = { hexToRgb, rgbToHex };Write a declaration file that covers just the two functions you use, plus a small RGB interface to describe the shape that hexToRgb returns.
// types/color-utils.d.ts
declare module "color-utils" {
interface RGB {
r: number;
g: number;
b: number;
}
export function hexToRgb(hex: string): RGB;
export function rgbToHex(r: number, g: number, b: number): string;
}With that declaration in place, your TypeScript code gets full type checking on both functions, including autocomplete on the returned RGB object.
import { hexToRgb, rgbToHex } from "color-utils";
const color = hexToRgb("#ff0000");
console.log(color.r); // 255 (TypeScript knows this is a number)
const hex = rgbToHex(255, 0, 0);
console.log(hex); // "#ff0000"Step 5: Handle Different Export Styles
Packages export in different ways depending on their module format. Match your declaration to the export style, since a mismatched shape produces a declaration that never lines up with how consumers actually import the package.
CommonJS with Default Export
Some packages assign a single function directly to module.exports, so importing the package gives you that function and nothing else.
// package exports a single function
module.exports = function parseQuery(url) {
return Object.fromEntries(new URLSearchParams(url));
};Match that shape with export =, which tells TypeScript the entire module is this one callable value rather than an object of named exports.
declare module "query-parser" {
function parseQuery(url: string): Record<string, string>;
export = parseQuery;
}CommonJS with Named Exports
Other packages export a plain object with multiple properties, closer to how a typical ES module looks.
// package exports an object with multiple properties
module.exports = { encode, decode };For this shape, use regular named export function declarations inside the declare module block, one for each property on the exported object, the same way you would declare exports for a normal ES module.
declare module "codec-lib" {
export function encode(input: string): Buffer;
export function decode(input: Buffer): string;
}Default Export with Additional Named Exports
A third common shape combines both: a callable default export with extra properties attached to it, a pattern common in older utility libraries.
// package has a default export plus named helpers
module.exports = mainFunction;
module.exports.helper = helperFunction;Declare the main function and the helper as siblings inside the block, then use export = on the main function. TypeScript automatically treats the helper as a property attached to it.
declare module "mixed-pkg" {
interface Config { verbose?: boolean; }
interface Result { success: boolean; }
function mainFunction(config: Config): Result;
function helperFunction(input: string): boolean;
export = mainFunction;
// helperFunction is inferred as a property on mainFunction.
}For more on the full range of export patterns, see declaration files in TypeScript.
Step 6: Type Classes and Constructors
If the package exports a class constructor, declare a class inside the module block and list its methods without bodies, the same way you would inside a normal declaration file.
declare module "event-emitter" {
class EventEmitter {
on(event: string, handler: (...args: unknown[]) => void): void;
emit(event: string, ...args: unknown[]): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}
export = EventEmitter;
}If the class also has static methods, like a factory method for creating connections, declare them inside the class body using the static keyword, exactly as you would in a runnable TypeScript class.
declare module "db-client" {
class Database {
static connect(url: string): Database;
query<T>(sql: string): Promise<T[]>;
close(): Promise<void>;
}
export = Database;
}Step 7: Type Callback-Heavy APIs
Many older JavaScript packages use callbacks instead of promises. Type these with a function signature whose last parameter is the callback, matching the error-first convention common in Node.js APIs.
declare module "legacy-fs" {
export function readFile(
path: string,
callback: (error: Error | null, data: string) => void
): void;
export function writeFile(
path: string,
data: string,
callback: (error: Error | null) => void
): void;
}For packages with config objects, use an interface to describe the options bag instead of inlining every property directly in the function signature. The example below adds an options parameter to a second overload of readFile.
declare module "legacy-fs" {
interface ReadOptions {
encoding?: string;
flag?: string;
}
export function readFile(
path: string,
options: ReadOptions,
callback: (error: Error | null, data: string) => void
): void;
}Build the types incrementally. Each time you use a new method from the package, add its signature to the declaration file.
When to Stop Typing
You do not need to type every function, every overload, and every edge case. Type what you use, and leave the rest for later. A partial declaration file is better than no declaration file.
If the package is small and well-documented, typing it completely may be worth the time. If it is large and you only use two functions, type those two and move on.
A pragmatic checklist:
| Situation | Action |
|---|---|
| You use 2-3 functions from a large package | Type only those functions |
| Package has good documentation | Type more detail since the contract is clear |
| Package is internal/private to your team | Full typing is a good investment |
| Package changes frequently | Keep declarations minimal to reduce maintenance |
| You will contribute types to DefinitelyTyped | Type the full public API |
Testing Your Types
The best way to verify your declarations is to use them. Write a small TypeScript file that imports the package and exercises the functions you typed:
// test-types.ts
import { hexToRgb } from "color-utils";
// Compiler should accept this:
const color = hexToRgb("#ff0000");
// Compiler should reject this:
const bad = hexToRgb(123);
// ^ Argument of type 'number' is not assignable to parameter of type 'string'.If the compiler accepts the correct call and rejects the incorrect one, your types are working. You do not need to run this test file -- just checking that it compiles is enough.
Organizing as You Scale
When you have types for several packages, keep them organized with one declaration file per package rather than combining them.
types/
untyped-pkg.d.ts
color-utils.d.ts
legacy-fs.d.ts
index.d.tsAvoid putting declarations for multiple packages in one file. It becomes hard to tell which declaration belongs to which package and makes it difficult to remove a type when you stop using a dependency.
For a more detailed pattern on ambient declarations, see ambient declarations in TypeScript.
Rune AI
Key Insights
- Use declare module 'pkg' { ... } to add types for an untyped npm package.
- Start with a minimal shim (declare module 'pkg';) to silence errors, then add real types incrementally.
- Put custom .d.ts files in a dedicated folder and reference them in tsconfig.json.
- Type only the parts of the API you actually use -- you can always add more later.
- Check DefinitelyTyped and the package's own repository for @types before writing your own.
Frequently Asked Questions
What do I do when a package has no @types and no bundled types?
Where should I put my custom declaration files?
Can I contribute my types back to DefinitelyTyped?
Conclusion
Typing untyped packages is a practical skill every TypeScript developer needs. You do not have to type every method the package exports. Start with what you use, add more as needed, and organize your declaration files so they are easy to find and maintain.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.