Opinionated TypeScript npm package template for ESM packages. Enforces src→dist builds with tsc, strict TypeScript defaults, explicit exports, and publish-safe package metadata. Use this when creating or updating any npm package in this repo.
Use this skill when scaffolding or fixing npm packages.
"type": "module"."description".repository with type, url, and directoryhomepagebugskeywords../package.json."." for runtime entrypoint (dist)"./src" and "./src/*" pointing to .ts source filestypes first.
"."typesdist"./src", "./src/*"), point types to source
files in src (not ./dist/*.d.ts).default in exports.files must include at least:
srcdistschema.prisma)README.md and CHANGELOG.mdskills/ directory if the package ships an agent skill (see "Agent
skill" section below). Skill files live at skills/<name>/SKILL.md,
never at the package root.scripts.build should be tsc && chmod +x dist/cli.js (skip the chmod if
the package has no bin). No bundling. Do not delete dist/ in build by
default because forcing a clean build on every local build can cause
issues. Optionally include running scripts with tsx if needed to
generate build artifacts.prepublishOnly must always do the cleanup before build (optionally run
generation before build when required). Always add this script:
{ "prepublishOnly": "rimraf dist \"*.tsbuildinfo\" && pnpm build" }
This ensures dist/ is fresh before every npm publish, so deleted files
do not accidentally stay in the published package. Use rimraf here
instead of bare shell globs so the script behaves the same in zsh, bash,
and Windows shells even when no .tsbuildinfo file exists.Use bin as a plain string pointing to the compiled entrypoint, not an object:
{ "bin": "dist/cli.js" }
The bin file must be executable and start with a shebang. After creating or building it, always run:
chmod +x dist/cli.js
Add the shebang as the first line of the source file (src/cli.ts):
#!/usr/bin/env node
tsc preserves the shebang in the emitted .js file. The chmod +x is
already part of the build script, so prepublishOnly still gets it through
pnpm build after the cleanup step.
When Node code needs the package version, prefer reading it from package.json
via createRequire. This works cleanly in ESM packages without adding a JSON
import assertion.
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const packageJson = require("../package.json") as {
version: string;
};
export const packageVersion = packageJson.version;
package.json.version.ESM does not have __dirname. Derive it from import.meta.url with the
node:url and node:path modules, then resolve relative paths from there.
import url from "node:url";
import path from "node:path";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
// e.g. from src/cli.ts → read SKILL.md under skills/<name>/SKILL.md
// (skill files always live in skills/<name>/SKILL.md, never at the package root)
const skillPath = path.resolve(__dirname, "../skills/mypkg/SKILL.md");
// from dist/cli.js (after tsc) → reach back to src/
const srcFile = path.resolve(__dirname, "../src/template.md");
tsc compiles src/ → dist/. At runtime the file lives in
dist/, so one .. gets you back to the package root.src/ during dev (running with tsx), .. also reaches the
package root since src/ is one level deep.path.resolve(__dirname, ...) instead of string concatenation so it
works on all platforms.Check whether import.meta.url ends with .ts or .tsx. In dev you run
source files directly (via tsx or bun), so the URL points to a .ts file.
After tsc builds to dist/, the URL ends with .js.
const isDev = import.meta.url.endsWith(".ts") || import.meta.url.endsWith(".tsx");
This is useful for conditionally resolving paths that differ between src/ and
dist/, or enabling dev-only logging without relying on NODE_ENV.
Use Node ESM-compatible compiler settings:
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"rootDir": "src",
"outDir": "dist",
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ESNext",
"lib": ["ESNext"],
"declaration": true,
"declarationMap": true,
"noEmit": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"useUnknownInCatchVariables": false
},
"include": ["src"]
}
"DOM" to lib only when browser globals are needed..ts and .tsx extensions in source imports. tsc rewrites them to
.js in the emitted dist/ output automatically via
rewriteRelativeImportExtensions. This means source code works directly in
runtimes like tsx, bun, and frameworks like Next.js that expect .ts
extensions, while the published dist/ has correct .js imports that Node.js
and other consumers resolve without issues.
// source (src/index.ts) — use .ts/.tsx extensions
import { helper } from './utils.ts'
import { Button } from './button.tsx'
// emitted output (dist/index.js) — tsc rewrites to .js
// import { helper } from './utils.js'
// import { Button } from './button.js'
paths in tsconfig) are
not supported by rewriteRelativeImportExtensions — this is fine since npm
packages should use relative imports anyway.@types/node as a dev dependency whenever Node APIs are used.scripts/*.ts and invoke them
from package scripts before build/publish.IMPORTANT! always use rootDir src. if there are other root level folders that should be type checked you should create other tsconfig.json files inside those folder. DO NOT add other folders inside src or the dist/ will contain dist/src, dist/other-folder. which breaks imports. the tsconfig.json inside these other folders can be minimal, using noEmit true, declaration false. Because usually these folders do not need to be emitted or compiled. just type checked. tests should still be put inside src. other folders can be things like
scriptsorfixtures.
{
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./src": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./src/*": {
"types": "./src/*.ts",
"default": "./src/*.ts" // or .tsx for packages that export React components. if so all files should end with .tsx
}
}
}
imports map (internal # aliases)Use imports when you need a package to swap between different implementations
based on runtime (Node vs Bun vs browser vs SQLite vs better-sqlite3, etc.).
Internal imports are #-prefixed, scoped to the package itself, and never
leak to consumers. Consumers resolve through exports, not imports.
types at dist, not srcThe TypeScript docs are explicit about this:
If the package.json is part of the local project, an additional remapping step is performed in order to find the input TypeScript implementation file... This remapping uses the
outDir/declarationDirandrootDirfrom the tsconfig.json, so using"imports"usually requires an explicitrootDirto be set.This variation allows package authors to write
"imports"and"exports"fields that reference only the compilation outputs that will be published to npm, while still allowing local development to use the original TypeScript source files.
In other words, TypeScript automatically walks from ./dist/foo.d.ts back
to ./src/foo.ts using outDir → rootDir during compilation. You do not
need to point types at src manually — let TypeScript remap it.
{
"imports": {
"#sqlite": {
"bun": "./src/platform/bun/sqlite.ts",
"node": {
"types": "./dist/platform/node/sqlite.d.ts",
"default": "./dist/platform/node/sqlite.js"
},
"default": {
"types": "./dist/platform/node/sqlite.d.ts",
"default": "./dist/platform/node/sqlite.js"
}
}
}
}
Resolution flow when tsc sees import db from '#sqlite':
imports["#sqlite"].node.types → ./dist/platform/node/sqlite.d.tsoutDir (dist) with rootDir (src) → ./src/platform/node/sqlite.d.ts.d.ts with the source extension .ts → ./src/platform/node/sqlite.ts./src/platform/node/sqlite.ts (it exists on a fresh clone, no build needed)../dist/platform/node/sqlite.d.ts.tsc works on a fresh
clone without dist/ existing yet.imports entry points at
something that will actually be in the npm tarball. No stale src paths
leaking into the published package.json.imports map at runtime and
resolves to real dist/*.js files that exist.src, because
those runtimes execute .ts directly and skip the build step.This only works when:
moduleResolution is node16, nodenext, or bundlerrootDir is set explicitly in tsconfig.json (the skill's tsconfig rules
already require "rootDir": "src")outDir is set (already in the template)resolvePackageJsonImports is not disabled (it is on by default for the
supported moduleResolution modes)types at src manually{
"imports": {
"#sqlite": {
"node": {
"types": "./src/platform/node/sqlite.ts", // ❌ don't do this
"default": "./dist/platform/node/sqlite.js"
}
}
}
}
This works but:
package.json advertises src/*.ts paths that may or may
not exist depending on what you include in files.imports feature.default — mixing source (for types) and dist
(for runtime) paths in the same entry is easy to get wrong.test files should be close with the associated source files. for example if you have an utils.ts file you will create utils.test.ts file next to it. with tests, importing from utils. preferred testing framework is vitest (or bun if project already using bun test or depends on bun APIs, rare)
If the package ships an agent skill (SKILL.md for AI coding agents), place it