You install a small internal package, import it, and the build stops with TS7016. The package works fine at runtime; it just has no types. There are three ways out: silence the error, find types someone else wrote, or write a declaration file yourself. The third is the one interviewers ask about, because doing it well means understanding how TypeScript models JavaScript modules.
Reproducing the error
Here is a tiny CommonJS package with no types, the kind you find in older internal repos:
// node_modules/tiny-slug/index.js
function slug(input, options) {
const sep = (options && options.separator) || '-';
let s = String(input).toLowerCase().trim().replace(/[^a-z0-9]+/g, sep);
s = s.replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '');
return options && options.maxLength ? s.slice(0, options.maxLength) : s;
}
slug.version = '1.2.0';
module.exports = slug;The application is an ES module project ("type": "module") compiled with module: nodenext and strict: true:
// src/app.ts
import slug from 'tiny-slug';
console.log(slug('Hello, World!'));
console.log(slug('Q3 Revenue Report', { separator: '_', maxLength: 10 }));
console.log(slug.version);TypeScript 7.0 reports:
src/app.ts(1,18): error TS7016: Could not find a declaration file for module 'tiny-slug'. '.../node_modules/tiny-slug/index.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/tiny-slug` if it exists or add a new declaration (.d.ts) file containing `declare module 'tiny-slug';`The message only appears under noImplicitAny (part of strict). Without it, the import quietly becomes any, which is worse, because nothing tells you the calls are unchecked.
The suggested one-liner, declare module 'tiny-slug';, is a shorthand ambient module. It removes the error by declaring that everything the module exports is any. That’s an acceptable stopgap in a migration, as long as it’s tracked. It isn’t a declaration file in any useful sense, and slug(42, { separatr: '_' }) compiles under it.
Writing the declaration
The first decision is how the module exports its value. Look at the JavaScript, not the README. This package does module.exports = slug: the export is the function, and the function also carries a version property. In TypeScript’s declaration syntax that’s export =, and a function with properties is best described with a callable interface:
// node_modules/tiny-slug/index.d.ts (or a file you keep in your repo, see below)
interface SlugOptions {
/** Character placed between words. Defaults to "-". */
separator?: string;
/** Truncate the result to this many characters. */
maxLength?: number;
}
interface Slug {
(input: string, options?: SlugOptions): string;
readonly version: string;
}
declare const slug: Slug;
export = slug;Placed next to the package’s index.js, this compiles cleanly, and running the compiled app prints:
hello-world
q3_revenue
1.2.0Now the declaration does real work. Feed it a few mistakes:
import slug from 'tiny-slug';
slug('x', { separator: '_', maxlength: 10 });
slug(42);
slug.version = '2.0.0';src/bad.ts(2,29): error TS2561: Object literal may only specify known properties, but 'maxlength' does not exist in type 'SlugOptions'. Did you mean to write 'maxLength'?
src/bad.ts(3,6): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/bad.ts(4,6): error TS2540: Cannot assign to 'version' because it is a read-only property.export = versus export default
This is the detail that separates people who have written declarations from people who have read about them. If you’d written export default slug instead, you’d be telling TypeScript the CommonJS module has a property named default, which this JavaScript doesn’t have. The ESM app’s default import then gets the whole module object, and every call fails to type-check:
src/app.ts(2,13): error TS2349: This expression is not callable.
Type 'typeof import(".../node_modules/tiny-slug/index")' has no call signatures.The checker is right and the declaration is wrong. Worse, under some older settings the mismatch compiles and fails at runtime instead. Use export default only when the JavaScript actually sets exports.default or is a real ES module with export default.
The default import works in this ESM app because Node.js gives ES modules the whole module.exports object as the default import of a CommonJS package, and nodenext models that. In a CommonJS project you’d want esModuleInterop: true for the same import to work; without it you’d write import slug = require('tiny-slug').
Where the file lives
You often can’t edit node_modules, so the declaration usually goes in your repo, say types/tiny-slug.d.ts, wrapped in a module declaration:
declare module 'tiny-slug' {
interface SlugOptions { separator?: string; maxLength?: number }
interface Slug {
(input: string, options?: SlugOptions): string;
readonly version: string;
}
const slug: Slug;
export = slug;
}Add "types" to your include list. There’s a wrinkle in an ESM project: TypeScript 7.0 treats types/tiny-slug.d.ts as an ES module file (the nearest package.json says "type": "module") and rejects the last line with TS1203: Export assignment cannot be used when targeting ECMAScript modules. Renaming the file to tiny-slug.d.cts marks it as describing CommonJS, and the project then compiles with no errors. In a CommonJS project the .d.ts name works as is.
If the package is yours, or open source, the better long-term home is next to the code: either commit index.d.ts to the package or contribute to DefinitelyTyped so @types/tiny-slug exists for everyone.
What a declaration file can’t do
A .d.ts is a promise about code that lives somewhere else, and nobody checks the promise. If tiny-slug 2.0 renames maxLength to limit, your declaration keeps compiling and your call sites keep passing a property the library ignores. Treat a hand-written declaration like a contract test: pin the package version, and when you upgrade, re-read the JavaScript for the functions you use.
It also can’t hold code. A body in a declaration file fails immediately:
export function add(a: number, b: number): number { return a + b; }types/impl.d.ts(1,51): error TS1183: An implementation cannot be declared in ambient contexts.One more trap: skipLibCheck: true, which most projects enable for speed, skips type-checking of all .d.ts files, including the ones you wrote. A typo that references a nonexistent type inside your declaration won’t be reported where it’s written. Compile once with skipLibCheck off when you add or edit a declaration.
Describing globals
Some code doesn’t come from an import at all. A server-rendered page might put configuration on window before your bundle loads. You describe that by augmenting the global scope, here in types/globals.d.ts:
declare global {
interface Window {
__APP_CONFIG__: { apiBase: string; release: string };
}
}That file fails as written:
types/globals.d.ts(1,9): error TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module declarations.A file with no top-level import or export is a script, and in a script everything is already global, so declare global has nothing to escape from. Add export {}; at the end to make it a module and the error goes away. (Alternatively, drop declare global and write interface Window at the top level of a script file; it merges the same way.) Interface merging is what makes this work: your Window adds a member to the DOM library’s Window instead of replacing it.
Shipping types with your own package
When the library is TypeScript, don’t hand-write declarations at all. Let the compiler emit them from the source. For this file:
export interface Money {
amountCents: number;
currency: 'USD' | 'EUR' | 'GBP';
}
export function formatMoney(m: Money, locale = 'en-US'): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency: m.currency }).format(m.amountCents / 100);
}
const cache = new Map<string, Intl.NumberFormat>();
export const VERSION = '0.3.1';tsc --declaration --emitDeclarationOnly produced:
export interface Money {
amountCents: number;
currency: 'USD' | 'EUR' | 'GBP';
}
export declare function formatMoney(m: Money, locale?: string): string;
export declare const VERSION = "0.3.1";Notice what the emitter did: the default parameter became an optional parameter, the unexported cache vanished, and the const kept its literal type. Then point consumers at the output. With an exports map, put the types condition first, because conditions are matched in order and TypeScript stops at the first one it understands:
{
"name": "@acme/money",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
}A top-level "types" field is still read by older resolution modes, so many packages keep both.
Practice question
A teammate adds types/analytics.d.ts containing declare module 'legacy-analytics'; to unblock a build, and also declares window.dataLayer in the same file using declare global. The build now fails with TS2669. Explain the failure, and describe what you’d ask for in review before merging.
Answer: The file has no top-level import or export, so TypeScript treats it as a script. In a script, declare global is an error (TS2669), because globals are already the default scope there. Adding export {}; fixes that error but creates another, because it changes how the whole file is read. Inside a module, declare module 'legacy-analytics' means “augment the existing module”, and an untyped package can’t be augmented, so TypeScript reports TS2665: Invalid module name in augmentation. Module 'legacy-analytics' resolves to an untyped module ... which cannot be augmented. The clean fix is two files: a script file (no imports or exports) for the package declaration and a module file (ending in export {}) for the globals.
In review I’d also push back on the shorthand declaration. It makes every call to legacy-analytics any, so a typo in an event name ships silently. I’d ask for a real declaration covering just the two or three functions the app uses, written against the package’s actual module.exports, with the package version pinned so the declaration can’t drift unnoticed.