#3208·lottie-web

Types: declarations use "export default" but the runtime uses "module.exports =", breaking ESM consumers on node16/nodenext

Author: GamoteCreated Jul 25, 2026Updated Jul 25, 2026

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 default where the JavaScript file appears to use module.exports =. This will cause TypeScript under the node16 module mode to think an extra .default property access is required, but that will likely fail at runtime. These types should use export = instead of export default.

Environment

  • [email protected]
  • TypeScript 5.9.3
  • The package has no "type" field, so it is CommonJS. main is ./build/player/lottie.js, types is ./index.d.ts

Reproduction

In a package with "type": "module":

jsonc
// tsconfig.json
{ "compilerOptions": { "module": "node16", "moduleResolution": "node16", "esModuleInterop": true } }
typescript
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:

typescript
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:

diff
-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:

diff
-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:

  1. 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, so cjs-module-lexer detects no named exports and Node rejects the import. Today TypeScript happens to reject it too, with TS2614. arethetypeswrong reflects this by swapping FalseExportDefault for NamedExports. Making named imports genuinely work would need a change to the JavaScript build, which is a much larger piece of work than this.

  2. A consumer on moduleResolution: node10 and esModuleInterop: false using a default import compiles today and would fail with export = (TS1259). Under bundler (which implies allowSyntheticDefaultImports) and under node16 there is no regression. Affected users would switch to import 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.