The no-bundler build tool for TypeScript libraries. Powered by tsc
.
by @colinhacks
zshy
is a bundler-free batteries-included build tool for transpiling TypeScript libraries. It was originally created as internal build tool for Zod but is now available as a general-purpose tool for TypeScript libraries.
- π§± Dual-module builds β Builds ESM and CJS outputs from a single TypeScript source file
- π 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
andtsconfig.json
- π Declarative entrypoint map β Specify your TypeScript entrypoints in
package.json#/zshy
- π€ Auto-generated
"exports"
β Writes"exports"
map directly into yourpackage.json
- π 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 yourtsconfig.json#/jsx*
settings - π CLI-friendly β First-class
"bin"
support - π Blazing fast β Just kidding, it's slow. But it's worth it
npm install --save-dev zshy yarn add --dev zshy pnpm add --save-dev zshy
{ "name": "my-pkg", "version": "1.0.0", + "zshy": { + "exports" : { + ".": "./src/index.ts" + } + } }
Run a build with npx zshy
:
$ 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 yourpackage.json
{ // ... "scripts": { + "build": "zshy" } }
Then, to run a build:
$ 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):
$ 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
:
{ // ... "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 transpilation - doesn't require a bundler
- doesn't require another config file (just
package.json
andtsconfig.json
)
$ npm zshy --help Usage: zshy [options] Options: -h, --help Show this help message -p, --project <path> Path to tsconfig (default: ./tsconfig.json) --verbose Enable verbose output --dry-run Don't write any files or update package.json --fail-threshold <threshold> When to exit with non-zero error code "error" (default) "warn" "never"
Multi-entrypoint packages can specify subpaths or wildcard exports with package.json#/zshy/exports
:
View typical build output
When you run a build, you'''ll see something like this:
$ 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 pathh β ββββββββββββΌβββββββββββββββββ’ β 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:
// 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.
{ // package.json "name": "my-cli", "version": "1.0.0", "type": "module", "zshy": { "bin": "./src/cli.ts" // π specify CLI entrypoint } }
The "bin"
field is automatically written into your package.json
:
{ // package.json "name": "my-cli", "version": "1.0.0", "zshy": { "exports": "./src/index.ts", "bin": "./src/cli.ts" }, + "bin": { + "my-cli": "./dist/cli.cjs" // CommonJS entrypoint + } }
It reads your package.json#/zshy
config:
// package.json { "name": "my-pkg", "version": "1.0.0", "zshy": { "exports": { ".": "./src/index.ts", "./utils": "./src/utils.ts", "./plugins/*": "./src/plugins/*" // matches all .ts/.tsx files in ./src/plugins } } }
Note β Since zshy
computes an exact set of resolved entrypoints, your "files"
, "include"
, and "exclude"
settings in tsconfig.json
are ignored during the build.
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 outputverbatimModuleSyntax
: Set tofalse
to allow multiple build formats
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 tooutDir
β you probably shouldn't set this explicitly)target
(defaults toes2020
)
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 β
tshy
requires you to put your source in a./src
directory, and always builds to./dist/esm
and./dist/cjs
.
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)
$ tree dist . βββ package.json # if type == "module" βββ src βΒ Β βββ index.ts βββ dist Β Β βββ index.js Β Β βββ index.d.ts Β Β βββ index.cts Β Β βββ 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)
$ tree dist . βββ package.json # if type != "module" βββ src βΒ Β βββ index.ts βββ dist Β Β βββ index.js Β Β βββ index.d.ts Β Β βββ index.mjs Β Β βββ index.d.mts
Comparison β
tshy
generates plain.js
/.d.ts
files into separatedist/esm
anddist/cjs
directories, each with a stubpackage.json
to enable proper module resolution in Node.js. This is more convoluted than the flat file structure generated byzshy
. It also causes issues with Module Federation.
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 conversionts.CompilerHost#writeFile
: Handles output file extension changes (.js
β.cjs
/.mjs
)
Comparison β
tshy
was designed to enable dual-package builds powered by thetsc
compiler. To make this work, it relies on a specific file structure and the creation of temporarypackage.json
files to accommodate the various idiosyncrasies of Node.js module resolution. It also requires the use of separatedist/esm
anddist/cjs
build subdirectories.
Yes! zshy
supports whatever import style you prefer:
from "./utils"
: classic extensionless importsfrom "./utils.js"
: ESM-friendly extensioned importsfrom "./util.ts"
: recently supported natively viarewriteRelativeImportExtensions
Use whatever you like; zshy
will rewrite all imports/exports properly during the build process.
Comparison β
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.
Your exports map is automatically written into your package.json
when you run zshy
. The generated exports map looks like this:
{ "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/*" + } + } }
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:
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: ESM files can require
CommonJS, but CommonJS files can't import
ESM. By having "types"
point to the .d.cts
declarations, this error will never happen. 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" and "Masquerading as CJS" writeups from Are The Types Wrong.
Comparison β
tshy
generates independent (but identical).d.ts
files indist/esm
anddist/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.
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:
-
Remove
"type": "module"
from yourpackage.json
(if present) -
Set
outDir: "."
in yourtsconfig.json
-
Configure
"exclude"
inpackage.json
to exclude all source files:{ // ... "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.
Not really. It uses tsc
to typecheck your codebase, which is a lot slower than using a bundler that strips types. That said:
- You should be type checking your code during builds
- TypeScript is about to get 10x faster
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.