Dependencies and Their Type Definitions
Where types come from when they are not in the package, community type packages, versions drifting apart, and typing a library that ships none.
Where types come from when they are not in the package, community type packages, versions drifting apart, and typing a library that ships none.
You install a package and the import is underlined:
Could not find a declaration file for module 'legacy-uploader'.
Try `npm i --save-dev @types/legacy-uploader` if it exists.There is no such package. The library ships no types. You have three options and one of them switches off checking for everything that touches it.
By the end of this lesson you will know where types come from, what to do when there are none, how to fix ones that are wrong without waiting for upstream, and why two copies of the same type definition can make a value incompatible with itself.
Bundled with the package, which is now the norm:
{
"name": "photo-tools",
"types": "./dist/index.d.ts"
}Or through the exports map, where — as the modules lesson
noted — types must be the first key in each condition block or
it is never reached.
From DefinitelyTyped, published as @types/name:
npm install --save-dev @types/expressThese are community-maintained descriptions of a package that ships none. They are versioned separately, which is where drift comes from.
Written by you, when neither exists.
Bundled with the package
The norm now. Versioned with the code, so they cannot drift.
@types from DefinitelyTyped
Community-written, versioned separately from the package they describe — which is exactly where drift comes from.
Written by you
A declaration file you own, describing only the part of the library you actually call.
skipLibCheck: true means the compiler does not check these
files internally. Nearly every project sets it, and it hides
conflicts between two packages that disagree about a shared
type.
Bad — silencing it.
Good — a declaration for what you use.
The suppression makes the import compile and everything reached
through it any — so the typo in endpiont, the misspelled
uplaod, and every value derived from the response are all
unchecked. A single @ts-expect-error at an import can remove
checking from an entire module.
The declaration takes ten minutes and covers only the three functions you call, which is the point: describe what you use, not the whole library. Nobody needs a complete definition, and an incomplete one is enormously better than none.
Point the compiler at it:
A .d.ts inside include is picked up automatically; typeRoots
matters when the files live elsewhere.
If your declaration turns out well, publishing it to DefinitelyTyped is a genuinely small contribution that saves the next person the same afternoon.
More annoying than missing types are inaccurate ones — a
parameter typed string that accepts string | number, a
return type missing null.
Module augmentation adds to an existing declaration:
Now request.user exists everywhere, which is the standard way
to describe what your middleware attaches. This works because
interfaces merge — the behaviour the foundations course flagged
as a hazard, here being the feature.
For a type that is wrong rather than incomplete, correct it at one boundary:
One assertion, in one file, with the rest of your codebase using the corrected signature. Contrast with asserting at forty call sites, which is forty places to update when upstream fixes it.
Mark it so it expires:
@ts-expect-error errors when there is nothing to suppress, so
the day the package is fixed your build tells you the comment is
stale — which @ts-ignore never does.
That message is genuinely confusing the first time. It means two
copies of a package are installed, so Photo from one is a
different type from Photo from the other — structurally
identical and nominally distinct, because they come from
different declaration files.
Three fixes, in order of preference:
Or a peer dependency, if you are the one publishing. Or
paths as a last resort, pointing every import at one copy.
The same happens with @types/react in a large front-end tree,
and it is the standard explanation for "this component is not
assignable to itself".
The rule is about what your consumers need:
Installed for everyone who installs you.
Code that runs at runtime — and, for a library, the @types
packages your public signatures mention.
Installed only for people working on you.
Build tools, test frameworks, linters, and the @types for
all of those.
The case people get wrong: if your package is a library and its
public types reference a package's types, that @types
package belongs in dependencies, not devDependencies.
A consumer needs @types/express to use that signature. In
devDependencies it is not installed for them, and they get an
error about a type they never mentioned.
For an application rather than a library, devDependencies is
correct for all of them — nothing consumes your types.
Three habits worth the small cost.
Audit before adding. Does it ship types? How large is it? When was it last updated? How many dependencies does it bring? A one-function package with fourteen transitive dependencies is a poor trade.
Pin exactly in an application. A lock file is not optional for anything you deploy — it is what makes the software you tested the software that runs.
Constrain ranges in a library. ^4.17.0, not 4.17.21. An
exact pin in a published library is an unsatisfiable conflict
for anyone using you and anything else that depends on the same
package.
And in CI:
depcheck's second category is the dangerous one: a package you
import without declaring works locally because something else
installed it, and breaks when that dependency changes.
You can now work with a package whatever state its types are in
— missing, wrong or duplicated — without giving up checking for
the code around it. The habit that pays most is writing a small
declare module instead of suppressing an import: ten minutes,
and the typos in your own calls are caught again.
Next is Utility Types, which covers the transformations that
ship with the language. Partial, Pick, Omit, Record and
the rest are how you derive one type from another instead of
maintaining two that drift.
Before you move on, run npm ls on a @types package you
depend on and check whether there is more than one version.
Duplicate type packages are common, quiet, and the explanation
for a class of error that otherwise makes no sense.
Argument of type 'Photo' is not assignable to parameter of
type 'Photo'. Two different types with this name exist.├── @types/node@20.11.0
└─┬ some-package
└── @types/node@18.19.0WHERE TYPES COME FROM
bundled "types" in package.json, or the exports map
("types" must be the FIRST key in each block)
@types/name DefinitelyTyped, versioned separately
yours a .d.ts you write
skipLibCheck hides conflicts between two packages
NO TYPES AT ALL
declare module "legacy-uploader" { ... }
describe only what you USE - an incomplete declaration is
enormously better than none
put it under include, or add typeRoots
never `@ts-expect-error` at the import: everything reached
through it becomes any, including your typos
TYPES THAT ARE WRONG
module augmentation to ADD:
declare module "express" { interface Request { user?: ... } }
works because interfaces merge
a wrapper to CORRECT: one assertion, one file, everyone else
uses the fixed signature
@ts-expect-error + a ticket, so it expires when upstream fixes it
"TWO DIFFERENT TYPES WITH THIS NAME"
two copies of the package are installed
npm ls @types/node
fix with overrides / resolutions; peerDependencies if publishing
the usual cause of "not assignable to itself"
WHERE THEY GO
dependencies runs at runtime
devDependencies build and test tooling
a LIBRARY whose public types reference @types/x needs it in
dependencies, or consumers get an error about a type they
never mentioned
HABITS
audit before adding: types? size? maintained? transitive count?
applications PIN with a lock file
libraries CONSTRAIN ranges - an exact pin is a conflict
npm audit and depcheck in CI
upgrade a library and its @types together// @ts-expect-error - no types
import uploader from "legacy-uploader";
const client = uploader.create({ endpiont: url }); // typo, no error
await client.uplaod(file); // no error// src/types/legacy-uploader.d.ts
declare module "legacy-uploader" {
export type UploaderOptions = {
endpoint: string;
timeoutMs?: number;
};
export type Uploader = {
upload(file: Buffer, name: string): Promise<{ id: string }>;
};
export function create(options: UploaderOptions): Uploader;
}import { create } from "legacy-uploader";
const client = create({ endpiont: url });
// ~~~~~~~~ not in UploaderOptions{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*"]
}// src/types/express.d.ts
import "express";
declare module "express" {
interface Request {
user?: { id: string; email: string };
}
}import { search as untypedSearch } from "some-library";
export function search(
query: string,
limit?: number,
): Promise<SearchResult[]> {
return untypedSearch(query, limit) as Promise<SearchResult[]>;
}// @ts-expect-error upstream types wrong, see some-library#4471npm ls @types/node// package.json - force one version for everyone
{
"overrides": {
"@types/node": "20.11.0"
}
}// your library's public API
export function handler(request: express.Request): void;npm audit --audit-level=high
npx depcheck # declared but unused, used but undeclared