Skip to content

Fix: publint Not Working — Package Exports Invalid, Types Not Found, or Dual Package Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix publint package validation issues — exports field configuration, dual ESM/CJS packaging, type resolution, main/module/types fields, files array, and common packaging mistakes.

The Problem

publint reports errors on your package:

npx publint
# ✗ "exports['.'].import.types" types is not the first in the object
# ✗ "main" file does not exist
# ✗ "module" field should be ESM but found CJS

Or consumers can’t import your package correctly:

import { something } from 'my-package';
// Error: Cannot find module 'my-package' or its corresponding type declarations

Or the package works in one environment but not another:

Works with: import { foo } from 'my-package'
Fails with: const { foo } = require('my-package')

Why This Happens

publint checks that your package.json is configured correctly for publishing to npm. Modern packages need to support multiple module systems and provide proper type information:

  • exports field is the modern standard — it replaces main, module, and types for Node.js 12+. But many tools still read the legacy fields, so you need both.
  • types must be first in conditional exports — TypeScript resolves types from the first matching condition. If types isn’t listed before default, TypeScript may not find your type declarations.
  • ESM and CJS have different file extensions.mjs is always ESM, .cjs is always CJS, .js follows "type" in package.json. Mismatched extensions cause “Cannot use import/require” errors.
  • files controls what’s published — if dist/ isn’t in the files array, npm publish excludes it, and consumers get an empty package.

The other half of the story is that the runtime ecosystem keeps fragmenting. Node.js, Bun, and Deno all read the exports field, but each prioritizes conditions slightly differently. Bun follows the Node.js resolution algorithm but also accepts a bun condition, which can shadow the node condition unexpectedly. Deno historically used import and deno conditions and only added full exports support in recent releases — older Deno versions silently fall back to a raw file lookup. The result: a package that passes publint on Node may still fail on one of the other runtimes if you do not declare the right conditions.

The third complication is how the package manager treats the publish lifecycle itself. npm runs prepublishOnly, then prepare, then prepack, then packs and uploads the tarball. pnpm reorders things slightly and tightens what it sees as “the published package” via its own pnpm pack implementation. Yarn (Berry) has its own pipeline with prepack and postpack, and Yarn workspaces apply workspace:* rewrites at publish time. If your dist/ is built during the wrong hook, the tarball will be missing files even though everything looks fine on disk. publint detects the symptom (“file does not exist”) but the root cause is the lifecycle, not the JSON.

Fix 1: Run publint and Fix Errors

npx publint
# Or check online: https://publint.dev/

# Common output:
# ✗ "exports['.'].import.types" types is not the first in the object
# ✗ "exports['.'].require" file does not exist
# ✓ "main" is valid
# ✓ "types" is valid

Fix each error type:

// WRONG — types is not first
{
  "exports": {
    ".": {
      "import": {
        "default": "./dist/index.js",
        "types": "./dist/index.d.ts"
      }
    }
  }
}

// CORRECT — types must be first
{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    }
  }
}

Fix 2: Correct package.json for Dual ESM/CJS

// package.json — the complete modern setup
{
  "name": "my-package",
  "version": "1.0.0",
  "description": "My awesome package",
  "type": "module",

  // Legacy fields — for older tools and bundlers
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",

  // Modern exports — takes precedence over main/module
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./utils": {
      "import": {
        "types": "./dist/utils.d.ts",
        "default": "./dist/utils.js"
      },
      "require": {
        "types": "./dist/utils.d.cts",
        "default": "./dist/utils.cjs"
      }
    },
    // Export CSS
    "./styles.css": "./dist/styles.css",
    // Export package.json (some tools need this)
    "./package.json": "./package.json"
  },

  // What's included in the published package
  "files": [
    "dist",
    "README.md",
    "LICENSE"
  ],

  // Side effects — enables tree-shaking
  "sideEffects": false,
  // Or specify files with side effects:
  // "sideEffects": ["./dist/styles.css"]

  // Engines
  "engines": {
    "node": ">=18"
  },

  // Keywords for npm search
  "keywords": ["utility", "typescript"],

  // Repository
  "repository": {
    "type": "git",
    "url": "https://github.com/user/my-package"
  },

  "license": "MIT",

  "scripts": {
    "build": "tsup",
    "lint": "publint && attw --pack",
    "prepublishOnly": "npm run build && npm run lint"
  }
}

Fix 3: ESM-Only Package

// Simpler setup when you don't need CJS support
{
  "name": "my-esm-package",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "sideEffects": false
}

Fix 4: CJS-Only Package (Legacy)

// For packages that must support older Node.js
{
  "name": "my-cjs-package",
  "version": "1.0.0",
  // No "type": "module" — defaults to CJS
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "require": "./dist/index.js"
    }
  },
  "files": ["dist"]
}

Fix 5: Validate with Multiple Tools

# publint — checks package.json configuration
npx publint

# arethetypeswrong — checks TypeScript resolution
npx @arethetypeswrong/cli --pack
# Shows how different moduleResolution settings resolve your types

# Are the Types Wrong output:
# ┌───────────────────┬──────────────────┬──────────────────┐
# │                   │ node16 (import)  │ node16 (require) │
# ├───────────────────┼──────────────────┼──────────────────┤
# │ "my-package"      │ OK               │ OK               │
# │ "my-package/utils"│ OK               │ No types         │
# └───────────────────┴──────────────────┴──────────────────┘

# npm pack --dry-run — see what will be published
npm pack --dry-run
# Lists all files that will be in the tarball

# Check package size
npx pkg-size
# Or: npx bundlephobia my-package
// package.json — validation scripts
{
  "scripts": {
    "build": "tsup",
    "check:exports": "publint",
    "check:types": "attw --pack",
    "check:size": "pkg-size",
    "prerelease": "npm run build && npm run check:exports && npm run check:types"
  }
}

Fix 6: Common Patterns and Fixes

// React component library
{
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./styles.css": "./dist/styles.css"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "sideEffects": ["*.css"]
}

// CLI tool
{
  "type": "module",
  "bin": {
    "my-cli": "./dist/cli.js"
  },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "files": ["dist"]
}

// Package with subpath exports (e.g., my-pkg/server, my-pkg/client)
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./server": {
      "types": "./dist/server.d.ts",
      "default": "./dist/server.js"
    },
    "./client": {
      "types": "./dist/client.d.ts",
      "default": "./dist/client.js"
    }
  }
}

Fix 7: Platform-Specific Resolution — Node vs Bun vs Deno

The same exports block resolves differently across runtimes. Declare conditions explicitly so each runtime gets the right file.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "bun": "./dist/index.bun.js",
      "deno": "./dist/index.deno.js",
      "workerd": "./dist/index.worker.js",
      "browser": "./dist/index.browser.js",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}

Runtime notes you need to know:

  • Node.js picks import for ESM callers and require for CJS callers. It ignores bun, deno, and workerd.
  • Bun prefers bun over import. If your bun entry points at a stale file, Bun consumers silently get the wrong build — Node-targeted CI passes, Bun-targeted CI fails. publint flags missing files but does not catch a wrong-but-existing target.
  • Deno reads deno first, then falls back to import. Older Deno versions (before full npm specifier support) ignored exports entirely and resolved against main — pin a Deno minimum if you rely on conditions.
  • Cloudflare Workers uses the workerd condition. Without it, the bundler picks browser or import and can pull in Node-only modules like fs, which fail at build time.

npm vs pnpm vs Yarn publish lifecycle: what actually ends up in the tarball depends on which manager you ran. npm publish runs prepublishOnly then prepack. pnpm publish skips lifecycle scripts unless you pass --no-ignore-scripts and applies publishConfig.directory if set. yarn npm publish (Berry) runs prepack/postpack and rewrites workspace:* ranges to concrete versions. If dist/ is missing from the published tarball, the cause is almost always one of:

# Run the publish in dry-run mode and inspect the tarball contents
npm pack --dry-run
pnpm pack --pack-destination /tmp
yarn pack --dry-run

# pnpm: if you build inside a workspace package, this is required
pnpm publish --no-git-checks --access public

Monorepo (workspaces) vs single package: in pnpm workspaces and Yarn workspaces, you typically run the build at the workspace root via Turborepo or Nx, then publish per package. publint runs against the individual package.json — but the exports paths must resolve relative to the package directory, not the workspace root. A common bug: dist/ lives at the workspace root because the build script writes there, while exports points to ./dist/index.js inside the package. publint says “file does not exist” and the fix is to either move the build output into the package or adjust exports to the workspace path.

ESM-CJS dual export warnings: publint also flags “dual package hazard” — when a consumer can import and require the same package, they get two different module instances. If your package holds state (a cache, a singleton, a class registry), the two copies will not see each other. Either ship ESM-only, or extract the stateful core to a separate package and re-export from both entries.

Still Not Working?

“types is not the first in the object” — in each exports condition block, the types key must come before default, import, or require. TypeScript resolves types from the first matching condition, so types must be first for correct resolution.

“file does not exist” — the file referenced in exports, main, module, or types isn’t in the built output. Run your build command first, then run publint. Check that files in package.json includes the dist/ directory.

Package works with import but not require — the require condition in exports is missing or points to an ESM file. ESM files (.js with "type": "module", or .mjs) can’t be require()d. Generate a .cjs output for CJS consumers.

Types resolve in one moduleResolution but not another — run npx @arethetypeswrong/cli --pack to check all resolution modes. node16 and bundler resolve differently. The types condition in exports fixes most issues. For node16 CJS, you need .d.cts files alongside .cjs files.

Works on Node, broken on Bun or Cloudflare Workers — you are missing a bun or workerd condition, or the matched condition points at a Node-only build. Run bun build --target=bun separately and verify the output works inside a real Bun process. For Workers, use wrangler dev to catch node:fs-style failures before publish.

Tarball is missing dist/ even though files is correct — your build did not run during the publish lifecycle. With pnpm, lifecycle scripts are skipped by default in some configurations — add prepack instead of prepublishOnly, since prepack always runs. With Yarn Berry, ensure dist/ is not gitignored and listed in files, because Berry intersects the two.

Subpath import works in your tests but fails when published — your test runner resolves through the source src/ while consumers resolve through dist/. Add a pnpm pack && npm install ./my-package-1.0.0.tgz step to a sandbox project and re-run the failing import there.

attw passes but a consumer still gets any types — the consumer’s tsconfig.json is set to "moduleResolution": "node" (the old algorithm), which ignores the exports field entirely and falls back to main/types. Tell consumers to switch to "moduleResolution": "bundler" or "node16"/"nodenext", and keep the legacy main/types fields populated as a safety net.

publint passes locally but the published tarball has the wrong files — you ran publint against the source package.json, but npm publish may rewrite paths via publishConfig. Verify the published artifact by running npm view your-package@latest --json and comparing the exports object against your local file. If publishConfig.directory is set, the package root inside the tarball is that directory, not the repo root, and all exports paths must be relative to it.

Tree-shaking does not work for consumers — your sideEffects field is missing or wrong. Set "sideEffects": false for pure libraries. For libraries with CSS imports, list the CSS files explicitly: "sideEffects": ["*.css", "./dist/polyfill.js"]. Bundlers like Rollup, esbuild, and Vite respect this and skip importing unused exports; consumers see smaller bundles.

For related packaging issues, see Fix: tsup Not Working, Fix: Changesets Not Working, Fix: pnpm Peer Dependency Error, and Fix: Turborepo Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles