Types: declarations use "export default" but the runtime uses "module.exports =", breaking ESM consumers on node16/nodenext
Summary
The type declarations use export default, but the package is CommonJS and its runtime uses module.exports =. For consumers whose own files are ESM, TypeScript's node16/nodenext resolution then types the default import as the module namespace rather than the player, so every call on it fails to type-check.
arethetypeswrong reports it as Incorrect default export:
The resolved types use
export defaultwhere the JavaScript file appears to usemodule.exports =. This will cause TypeScript under the node16 module mode to think an extra.defaultproperty access is required, but that will likely fail at runtime. These types should useexport =instead ofexport default.
Environment
[email protected]- TypeScript 5.9.3
- The package has no
"type"field, so it is CommonJS.mainis./build/player/lottie.js,typesis./index.d.ts
Reproduction
In a package with "type": "module":
// tsconfig.json
{ "compilerOptions": { "module": "node16", "moduleResolution": "node16", "esModuleInterop": true } }import lottie from "lottie-web";
lottie.loadAnimation({ container, animationData });error TS2740: Type 'typeof import(".../lottie-web/index")' is missing the following
properties from type 'LottiePlayer': play, pause, stop, setSpeed, and 9 more.A CommonJS consumer does not hit this, which is likely why it has gone unnoticed.
Why it happens
index.d.ts ends with:
declare const Lottie: LottiePlayer;
export default Lottie;In a CommonJS declaration file, export default asserts that module.exports.default is the player. The built files do module.exports = factory, so no .default property exists.
When Node imports a CommonJS module from ESM it synthesises a default export equal to the whole module.exports object, and node16/nodenext model that faithfully. There is no spelling that works in both places: lottie.default satisfies the type-checker and is undefined at runtime, while the plain default import runs correctly and does not type-check.
Suggested fix
export = is the declaration form for a CommonJS module with a single main export. It cannot be combined with other exported elements, which TypeScript reports as TS2309, so the 22 exported types need to move into a namespace merged with the const:
-export type AnimationDirection = 1 | -1;
+type AnimationDirection = 1 | -1;
...
declare const Lottie: LottiePlayer;
-export default Lottie;
+declare namespace Lottie {
+ export {
+ AnimationDirection,
+ /* ...the remaining 21 names... */
+ LottiePlayer,
+ };
+}
+
+export = Lottie;Declaration merging is what keeps import type { AnimationItem } from "lottie-web" working after the change.
The six build/player/*.d.ts files then re-export the merged entity:
-import { default as Lottie } from '../../index';
-
-export * from '../../index';
-
-export default Lottie;
+import Lottie = require('../../index');
+
+export = Lottie;That preserves everything export * provided, because the namespace carries the types along with the value.
These are declaration files only, so no JavaScript changes and nothing is compiled.
Compatibility, measured
Tested against [email protected] with TypeScript 5.9.3, skipLibCheck: false, esModuleInterop: true, consumer package "type": "module":
| consumer import | resolution | today | with the fix |
|---|---|---|---|
import lottie from "lottie-web" |
bundler | OK | OK |
import lottie from "lottie-web" |
node16 | fails | fixed |
import * as lottie from "lottie-web" |
bundler | fails | fixed |
import * as lottie from "lottie-web" |
node16 | fails | fixed |
import type { AnimationItem } from "lottie-web" |
bundler / node16 | OK | OK |
import l from "lottie-web/build/player/lottie_light.js" |
bundler | OK | OK |
import l from "lottie-web/build/player/lottie_light.js" |
node16 | fails | fixed |
import type { AnimationItem } from ".../lottie_light.js" |
bundler / node16 | OK | OK |
Four currently-broken combinations become correct and nothing that works today regresses.
Two consequences worth stating plainly:
import { loadAnimation } from "lottie-web"becomes type-legal after the change, but it does not work at runtime. The UMD wrapper assigns its exports dynamically, socjs-module-lexerdetects no named exports and Node rejects the import. Today TypeScript happens to reject it too, withTS2614.arethetypeswrongreflects this by swappingFalseExportDefaultforNamedExports. Making named imports genuinely work would need a change to the JavaScript build, which is a much larger piece of work than this.A consumer on
moduleResolution: node10andesModuleInterop: falseusing a default import compiles today and would fail withexport =(TS1259). Underbundler(which impliesallowSyntheticDefaultImports) and undernode16there is no regression. Affected users would switch toimport lottie = require("lottie-web")or enable the flag.
Compatibility with future ESM output
If ESM builds and an exports map land later (related: #2962, #1664), the declarations would move to export default alongside a genuine ESM entry point. This change does not close that door; it describes the package as it ships today.
I am applying this locally in a downstream package (lottie-react) via a package-manager patch, and it has been through a full build and test cycle there, so the change is not theoretical. A pull request is open at #3209.
Source: airbnb/lottie-web