Gold-standard build tool for TypeScript libraries
npm install zshy
zshyThe no-bundler build tool for TypeScript libraries. Powered by tsc.
by @colinhacks
zshy?zshy (zee-shy) is a bundler-free batteries-included build tool for transpiling TypeScript libraries. It was originally created as an internal build tool for Zod but is now available as a general-purpose tool for TypeScript libraries.
- π Powered by tsc β The gold standard for TypeScript transpilation
- π¦ Bundler-free β No bundler or bundler configs involved
- π¦ No config file β Reads from your package.json and tsconfig.json
- π Declarative entrypoint map β Specify your TypeScript entrypoints in package.json#/zshy
- π€ Auto-generated "exports" β Writes "exports" map directly into your package.json and jsr.json
- π§± Dual-module builds β Builds ESM and CJS outputs from a single TypeScript source file
- π Unopinionated β Use any file structure or import extension syntax you like
- π¦ Asset handling β Non-JS assets are copied to the output directory
- βοΈ Supports .tsx β Rewrites to .js/.cjs/.mjs per your tsconfig.json#/jsx* settings
- π CLI-friendly β First-class "bin" support
- π Blazing fast β Just kidding, it's slow. But it's worth it
``bash`
npm install --save-dev zshy
yarn add --dev zshy
pnpm add --save-dev zshy
Single entrypoint:
`diff`
// package.json
{
"name": "my-pkg",
"version": "1.0.0",
+ "zshy": "./src/index.ts"
}
Multiple entrypoints:
`diff
// package.json
{
"name": "my-pkg",
"version": "1.0.0",
+ "zshy": {
+ "exports": {
+ ".": "./src/index.ts",
+ "./utils": "./src/utils.ts",
+ "./plugins/": "./src/plugins/", // wildcard
+ "./components//": "./src/components//" // deep wildcard
+ }
+ }
}
`
Run a build with npx zshy:
`bash
$ npx zshy # use --dry-run to try it out without writing/updating files
β Starting zshy build π
β Detected project root: /Users/colinmcd94/Documents/projects/zshy
β Reading package.json from ./package.json
β Reading tsconfig from ./tsconfig.json
β Cleaning up outDir...
β Determining entrypoints...
ββββββββββββββ€βββββββββββββββββ
β Subpath β Entrypoint β
ββββββββββββββΌβββββββββββββββββ’
β "my-pkg" β ./src/index.ts β
ββββββββββββββ§βββββββββββββββββ
β Resolved build paths:
ββββββββββββ€βββββββββββββββββ
β Location β Resolved path β
ββββββββββββΌβββββββββββββββββ’
β rootDir β ./src β
β outDir β ./dist β
ββββββββββββ§βββββββββββββββββ
β Package is an ES module (package.json#/type is "module")
β Building CJS... (rewriting .ts -> .cjs/.d.cts)
β Building ESM...
β Updating package.json#/exports...
β Updating package.json#/bin...
β Build complete! β
`
> Add a "build" script to your package.json
>
> `diff`
> {
> // ...
> "scripts": {
> + "build": "zshy"
> }
> }
> `
>
> Then, to run a build:
>
> bash`
> $ npm run build
>
Vanilla tsc does not perform _extension rewriting_; it will only ever transpile a .ts file to a .js file (never .cjs or .mjs). This is the fundamental limitation that forces library authors to use bundlers or bundler-powered tools like tsup, tsdown, or unbuild...
...until now! zshy works around this limitation using the official TypeScript Compiler API, which provides some powerful (and criminally under-utilized) hooks for customizing file extensions during the tsc build process.
Using these hooks, zshy transpiles each .ts file to .js/.d.ts (ESM) and .cjs/.d.cts (CommonJS):
`bash`
$ tree .
βββ package.json
βββ src
β βββ index.ts
βββ dist # generated
βββ index.js
βββ index.cjs
βββ index.d.ts
βββ index.d.cts
Similarly, all relative import/export statements are rewritten to include the appropriate file extension. (Other tools like tsup or tsdown do the same, but they require a bundler to do so.)
| Original path | Result (ESM) | Result (CJS) |
| ------------------ | ------------------ | ------------------- |
| from "./util" | from "./util.js" | from "./util.cjs" |from "./util.ts"
| | from "./util.js" | from "./util.cjs" |from "./util.js"
| | from "./util.js" | from "./util.cjs" |
Finally, zshy automatically writes "exports" into your package.json:
`diff`
{
// ...
"zshy": {
"exports": "./src/index.ts"
},
+ "exports": { // auto-generated by zshy
+ ".": {
+ "types": "./dist/index.d.cts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ }
}
The result is a tool that I consider to be the "holy grail" of TypeScript library build tools:
- performs dual-module (ESM + CJS) builds
- type checks your code
- leverages tsc for gold-standard transpilationpackage.json
- doesn't require a bundler
- doesn't require another config file (just and tsconfig.json)
`sh
$ npx zshy --help
Usage: zshy [options]
Options:
-h, --help Show this help message
-p, --project
--verbose Enable verbose output
--dry-run Don't write any files or update package.json
--fail-threshold
"error" (default)
"warn"
"never"
`
Multi-entrypoint packages can specify subpaths or wildcard exports in package.json#/zshy/exports:
`jsonc
{
"name": "my-pkg",
"version": "1.0.0",
"zshy": {
"exports": {
".": "./src/index.ts", // root entrypoint
"./utils": "./src/utils.ts", // subpath
"./plugins/": "./src/plugins/", // wildcard
"./components/": "./src/components//" // deep wildcard
}
}
}
`
View typical build output
When you run a build, you'll see something like this:
`bash
$ npx zshy
β Starting zshy build... π
β Detected project root: /path/to/my-pkg
β Reading package.json from ./package.json
β Reading tsconfig from ./tsconfig.json
β Determining entrypoints...
ββββββββββββββββββββββ€ββββββββββββββββββββββββββββββ
β Subpath β Entrypoint β
ββββββββββββββββββββββΌββββββββββββββββββββββββββββββ’
β "my-pkg" β ./src/index.ts β
β "my-pkg/utils" β ./src/utils.ts β
β "my-pkg/plugins/" β ./src/plugins/ (5 matches) β
ββββββββββββββββββββββ§ββββββββββββββββββββββββββββββ
β Resolved build paths:
ββββββββββββ€βββββββββββββββββ
β Location β Resolved path β
ββββββββββββΌβββββββββββββββββ’
β rootDir β ./src β
β outDir β ./dist β
ββββββββββββ§βββββββββββββββββ
β Package is ES module (package.json#/type is "module")
β Building CJS... (rewriting .ts -> .cjs/.d.cts)
β Building ESM...
β Updating package.json exports...
β Build complete! β
`
And the generated "exports" map will look like this:
`diff`
// package.json
{
// ...
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.cts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./utils": {
+ "types": "./dist/utils.d.cts",
+ "import": "./dist/utils.js",
+ "require": "./dist/utils.cjs"
+ },
+ "./plugins/*": {
+ "types": "./dist/src/plugins/*",
+ "import": "./dist/src/plugins/*",
+ "require": "./dist/src/plugins/*"
+ }
+ }
}
If your package is a CLI, specify your CLI entrypoint in package.json#/zshy/bin. zshy will include this entrypoint in your builds and automatically set "bin" in your package.json.
`diff`
{
// package.json
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"zshy": {
+ "bin": "./src/cli.ts"
}
}
The "bin" field is automatically written into your package.json:
`diff`
{
// package.json
"name": "my-cli",
"version": "1.0.0",
"zshy": {
"exports": "./src/index.ts",
"bin": "./src/cli.ts"
},
+ "bin": {
+ "my-cli": "./dist/cli.cjs" // CLI entrypoint
+ }
}
> Note β The "bin" field defaults to the CJS build unless you have disabled it with "cjs": false.
Multiple CLIs
For packages that expose multiple CLI tools, "bin" can also be an object mappingeach command name to its source file:
`jsonc`
{
// package.json
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"zshy": {
"bin": {
"my-cli": "./src/cli.ts",
"other": "./src/other.ts"
}
}
}
This generates a corresponding object "bin" field:
`diff`
{
// package.json
"name": "my-cli",
"version": "1.0.0",
"zshy": {
"exports": "./src/index.ts",
"bin": {
"my-cli": "./src/cli.ts",
"other": "./src/other.ts"
}
},
"bin": {
"my-cli": "./dist/cli.cjs",
"other": "./dist/other.cjs"
}
}
Be sure to include a shebang>) as the first line of your CLI entrypoint file:
`ts
#!/usr/bin/env node
// CLI code here
`
For packages that only need ESM builds, you can disable CommonJS output entirely:
`jsonc`
{
"zshy": {
"exports": { ... },
"cjs": false
}
}
This will generate only ESM files (.js and .d.ts) and the package.json#/exports will only include "import" and "types" conditions.
`jsonc`
{
// package.json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
To specify custom conditions in your export maps, specify a "conditions" map. Each condition name must correspond to one of "src" | "esm" | "cjs".
`diff`
{
"zshy": {
"exports": {
".": "./src/index.ts"
},
+ "conditions": {
+ "my-src-condition": "src"
+ "my-esm-condition": "esm",
+ "my-cjs-condition": "cjs"
+ }
}
With this addition, zshy will add the "my-source" condition to the generated "exports" map:
`diff`
// package.json
{
"exports": {
".": {
+ "my-src-condition": "./src/index.ts",
+ "my-esm-condition": "./dist/index.js",
+ "my-cjs-condition": "./dist/index.cjs"
"types": "./dist/index.d.cts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
For packages that also have a jsr.json file for publishing to JSR, zshy will copy your configured exports to jsr.json/#exports, making your zshy configuration the single source of truth for exports.
This will copy over the paths of the source code entrypoints, not the paths to the transpiled code, since JSR supports and encourages publishing TypeScript source code rather than pairs of .js + .d.ts files.
It reads your package.json#/zshy config:
`jsonc`
// package.json
{
"name": "my-pkg",
"version": "1.0.0",
"zshy": {
"exports": {
".": "./src/index.ts",
"./utils": "./src/utils.ts",
"./plugins/": "./src/plugins/", // shallow match {.ts,.tsx,.cts,.mts} files
"./components/": "./src/components//" // deep match *.{.ts,.tsx,.cts,.mts} files
}
}
}
A few important notes about package.json#/zshy/exports:
- All keys should start with "./"package.json
- All values should be relative paths to source files (resolved relative to the file)
A few notes on wildcards exports:
- The _key_ should always end in "/*""/"
- The _value_ should correspond to a glob-like path value that ends in either (shallow match) or "//" (deep match)zshy
- Do not include a file extensions! matches source files with the following extensions:.ts
- , .tsx, .cts, .mts./
- A shallow match (
) will match both:
- ./
- ./.
- A deep match (./) will match all files recursively in the specified directory, including subdirectories:
- ./
- ./Note β Since
zshy computes an exact set of resolved entrypoints, your "files", "include", and "exclude" settings in tsconfig.json are ignored during the build.
$3
Yes! With some strategic overrides:
-
module: Overridden ("commonjs" for CJS build, "esnext" for ESM build)
- moduleResolution: Overridden ("node10" for CJS, "bundler" for ESM)
- declaration/noEmit/emitDeclarationOnly: Overridden to ensure proper output
- verbatimModuleSyntax: Set to false to allow multiple build formats
- esModuleInterop: Set to true (it's a best practice)
- composite: Set to false to avoid resolution issues. zshy will build all files that are reachable from your specified entrypoints.All other options are respected as defined, though
zshy will also set the following reasonable defaults if they are not explicitly set:-
outDir (defaults to ./dist)
- declarationDir (defaults to outDir β you probably shouldn't set this explicitly)
- target (defaults to es2020)
$3
No. You can organize your source however you like;
zshy will transpile your entrypoints and all the files they import, respecting your tsconfig.json settings.> Comparison to
tshy β tshy requires you to put your source in a ./src directory, and always builds to ./dist/esm and ./dist/cjs.
$3
It depends on your
package.json#/type field. If your package is ESM (that is, "type": "module" in package.json):-
.js + .d.ts (ESM)
- .cjs + .d.cts (CJS)`bash
$ tree dist.
βββ package.json # if type == "module"
βββ src
βΒ Β βββ index.ts
βββ dist
Β Β βββ index.js
Β Β βββ index.d.ts
Β Β βββ index.cjs
Β Β βββ index.d.cts
`Otherwise, the package is considered _default-CJS_ and the ESM build files will be rewritten as
.mjs/.d.mts.-
.mjs + .d.mts (ESM)
- .js + .d.ts (CJS)`bash
$ tree dist
.
βββ package.json # if type != "module"
βββ src
βΒ Β βββ index.ts
βββ dist
Β Β βββ index.js
Β Β βββ index.d.ts
Β Β βββ index.mjs
Β Β βββ index.d.mts
`> Comparison to
tshy β tshy generates plain .js/.d.ts files into separate dist/esm and dist/cjs directories, each with a stub package.json to enable proper module resolution in Node.js. This is more convoluted than the flat file structure generated by zshy. It also causes issues with Module Federation.
$3
zshy uses the TypeScript Compiler API to rewrite file extensions during the tsc emit step.- If
"type": "module"
- .ts becomes .js/.d.ts (ESM) and .cjs/.d.cts (CJS)
- Otherwise:
- .ts becomes .mjs/.d.mts (ESM) and .js/.d.ts (CJS)Similarly, all relative
import/export statements are rewritten to account for the new file extensions.| Original path | Result (ESM) | Result (CJS) |
| ------------------ | ------------------ | ------------------- |
|
from "./util" | from "./util.js" | from "./util.cjs" |
| from "./util.ts" | from "./util.js" | from "./util.cjs" |
| from "./util.js" | from "./util.js" | from "./util.cjs" |TypeScript's Compiler API provides dedicated hooks for performing such transforms (though they are criminally under-utilized).
-
ts.TransformerFactory: Provides AST transformations to rewrite import/export extensions before module conversion
- ts.CompilerHost#writeFile: Handles output file extension changes (.js β .cjs/.mjs)> Comparison to
tshy β tshy was designed to enable dual-package builds powered by the tsc compiler. To make this work, it relies on a specific file structure and the creation of temporary package.json files to accommodate the various idiosyncrasies of Node.js module resolution. It also requires the use of separate dist/esm and dist/cjs build subdirectories.
$3
Yes!
zshy supports whatever import style you prefer:-
from "./utils": classic extensionless imports
- from "./utils.js": ESM-friendly extensioned imports
- from "./util.ts": recently supported natively viarewriteRelativeImportExtensionsUse whatever you like;
zshy will rewrite all imports/exports properly during the build process.> Comparison to
tshy β tshy forces you to use .js imports throughout your codebase. While this is generally a good practice, it's not always feasible, and there are hundreds of thousands of existing TypeScript codebases reliant on extensionless imports.
$3
Your exports map is automatically written into your
package.json when you run zshy. The generated exports map looks like this:`diff
{
"zshy": {
"exports": {
".": "./src/index.ts",
"./utils": "./src/utils.ts",
"./plugins/": "./src/plugins/"
}
},
+ "exports": { // auto-generated by zshy
+ ".": {
+ "types": "./dist/index.d.cts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./utils": {
+ "types": "./dist/utils.d.cts",
+ "import": "./dist/utils.js",
+ "require": "./dist/utils.cjs"
+ },
+ "./plugins/*": {
+ "import": "./dist/src/plugins/*",
+ "require": "./dist/src/plugins/*"
+ }
+ }
}
`
$3
The
"types" field always points to the CJS declaration file (.d.cts). This is an intentional design choice. It solves the "Masquerading as ESM" issue. You've likely seen this dreaded error before:`ts
import mod from "pkg"; ^^^^^
// ^ The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("pkg")' call instead.
`Simply put, an ESM file can
import CommonJS, but CommonJS files can't require ESM. By having "types" point to the .d.cts declarations, we can always avoid the error above. Technically we're tricking TypeScript into thinking our code is CommonJS; in practice, this has no real consequences and maximizes compatibility.To learn more, read the "Masquerading as ESM" writeup from ATTW.
> Comparison to
tshy β tshy generates independent (but identical) .d.ts files in dist/esm and dist/cjs. This can cause Excessively Deep errors if users of the library use declaration merging (declare module {}) for plugins/extensions. Zod, day.js, and others rely on this pattern for plugins.
$3
This is expected behavior when running the "Are The Types Wrong" tool. This warning does not cause any resolution issues (unlike "Masquerading as ESM"). Technically, we're tricking TypeScript into thinking our code is CommonJS; when in fact it may be ESM. The ATTW tool is very rigorous and flags this; in practice, this has no real consequences and maximizes compatibility (Zod has relied on the CJS masquerading trick since it's earliest days.)
To learn more, read the "Masquerading as CJS" writeup from ATTW.
$3
CJS interop transform βΒ When a file contains a single
export default ... and _no named exports_...`ts
function hello() {
console.log("hello");
}export default hello;
`...the built
.cjs code will assign the exported value directly to module.exports:`ts
function hello() {
console.log("hello");
}
exports.default = hello;
module.exports = exports.default;
`...and the associated
.d.cts files will use export = syntax:`ts
declare function hello(): void;
export = hello;
`The ESM build is not impacted by this transform.
ESM interop transform βΒ Similarly, if a source
.ts file contains the following syntax:`ts
export = ...
`...the generated _ESM_ build will transpile to the following syntax:
`ts
export default ...
`
$3
Yes! This is one of the key reasons
zshy was originally developed. Many environments don't support package.json#/exports yet:- Node.js v12.7 or earlier
- React Native - The Metro bundler does not support
"exports" by default
- TypeScript projects with legacy configs β e.g. "module": "commonjs"This causes issues for packages that want to use subpath imports to structure their package. Fortunately
zshy unlocks a workaround I call a _flat build_:1. Remove
"type": "module" from your package.json (if present)
2. Set outDir: "." in your tsconfig.json
3. Configure "exclude" in package.json to exclude all source files:
`jsonc
{
// ...
"exclude": ["/.ts", "/.tsx", "/.cts", "/.mts", "node_modules"]
}
`With this setup, your build outputs (
index.js, etc) will be written to the package root. Older environments will resolve imports like "your-library/utils" to "your-library/utils/index.js", effectively simulating subpath imports in environments that don't support them.
$3
Yes. If you prefer to manage your export fields manually, you can prevent
zshy from making any changes by setting the noEdit option to true in your package.json#/zshy config.`jsonc
{
"zshy": {
"exports": "./src/index.ts",
"noEdit": true
}
}
`When
noEdit is enabled, zshy will build your files but will not write to package.json or jsr.json. You will be responsible for populating the "exports", "bin", "main", "module", and "types" fields yourself.
$3
Not really. It uses
tsc to typecheck your codebase, which is a lot slower than using a bundler that strips types. That said:1. You _should_ be type checking your code during builds
2. TypeScript is about to get 10x faster
Acknowledgements
The DX of
zshy was heavily inspired by tshy by @isaacs, particularly its declarative entrypoint map and auto-updating of package.json#/exports. It proved that there's a modern way to transpile libraries using pure tsc (and various package.json hacks). Unfortunately its approach necessarily involved certain constraints that made it unworkable for Zod (described in the FAQ in more detail). zshy borrows elements of tshy`'s DX while using the Compiler API to relax these constraints and provide a more "batteries included" experience.