Uh oh!
There was an error while loading.Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork0
🐒 Bundler-free build tool for TypeScript libraries. Powered by tsc. WITH DEV MODE
License
wevm/wshy
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
The no-bundler build tool for TypeScript libraries. Powered bytsc.
by@colinhacks
zshy (zee-shy) is a bundler-free batteries-included build tool for transpiling TypeScript libraries. It was originally created as an internal build tool forZod 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.jsonandtsconfig.json - 🔗No rebuild/watch modes — Use
--devto symlink dist to source for live development - 📝Declarative entrypoint map — Specify your TypeScript entrypoints in
package.json#/zshy - 🤖Auto-generated
"exports"— Writes"exports"map directly into yourpackage.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/.mjsper yourtsconfig.json#/jsx*settings - 🐚CLI-friendly — First-class
"bin"support - 🐌Blazing fast — Just kidding, it's slow. Butit's worth it
npm install --save-dev zshyyarn add --dev zshypnpm add --save-dev zshy
Single entrypoint:
// package.json{ "name": "my-pkg", "version": "1.0.0",+ "zshy": "./src/index.ts"}Multiple entrypoints:
// 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 withnpx 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
Vanillatsc does not performextension 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 liketsup,tsdown, orunbuild...
...until now!zshy works around this limitation using the officialTypeScript Compiler API, which provides some powerful (and criminally under-utilized) hooks for customizing file extensions during thetsc 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 relativeimport/export statements are rewritten to include the appropriate file extension. (Other tools liketsup ortsdown 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 yourpackage.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
tscfor gold-standard transpilation - doesn't require a bundler
- doesn't require another config file (just
package.jsonandtsconfig.json)
$ npx zshy --helpUsage: zshy [options]Options: -h, --help Show thishelp message -p, --project<path> Path to tsconfig (default: ./tsconfig.json) --verbose Enable verbose output --dev Enable development mode (symlink dist to source) --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 inpackage.json#/zshy/exports:
{"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:
$ 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:
// 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 inpackage.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" }}The"bin" field is automatically written into yourpackage.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" // 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:
{// 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:
{ // 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 ashebang as the first line of your CLI entrypoint file:
#!/usr/bin/env node// CLI code hereFor packages that only need ESM builds, you can disable CommonJS output entirely:
{"zshy": {"exports": {... },"cjs":false }}This will generate only ESM files (.js and.d.ts) and thepackage.json#/exports will only include"import" and"types" conditions.
{// 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".
{ "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:
// 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" } }}It reads yourpackage.json#/zshy config:
// 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 aboutpackage.json#/zshy/exports:
- All keys should start with
"./" - All values should be relative paths to source files (resolved relative to the
package.jsonfile)
A few notes on wildcards exports:
- Thekey should always end in
"/*" - Thevalue should correspond to a glob-like path value that ends in either
"/*"(shallow match) or"/**/*"(deep match) - Do not include a file extensions!
zshymatches source files with the following extensions:.ts,.tsx,.cts,.mts
- A shallow match (
./<dir>/*) will match both:./<dir>/*.{ts,tsx,cts,mts}./<dir>/*/index.{ts,tsx,cts,mts}.
- A deep match (
./<dir>/**/*) will match all files recursively in the specified directory, including subdirectories:./<dir>/**/*.{ts,tsx,cts,mts}./<dir>/**/*/index.{ts,tsx,cts,mts}
Note — Sincezshy computes an exact set of resolved entrypoints, your"files","include", and"exclude" settings intsconfig.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 tofalseto allow multiple build formatsesModuleInterop: Set totrue(it's a best practice)composite: Set tofalseto avoid resolution issues.zshywill build all files that are reachable from your specified entrypoints.
All other options are respected as defined, thoughzshy 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 yourtsconfig.json settings.
Comparison to
tshy—tshyrequires you to put your source in a./srcdirectory, and always builds to./dist/esmand./dist/cjs.
It depends on yourpackage.json#/type field. If your package is ESM (that is,"type": "module" inpackage.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.cjs └── index.d.cts
Otherwise, the package is considereddefault-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 to
tshy—tshygenerates plain.js/.d.tsfiles into separatedist/esmanddist/cjsdirectories, each with a stubpackage.jsonto enable proper module resolution in Node.js. This is more convoluted than the flat file structure generated byzshy. It also causes issues withModule Federation.
zshy uses theTypeScript Compiler API to rewrite file extensions during thetsc emit step.
- If
"type": "module".tsbecomes.js/.d.ts(ESM) and.cjs/.d.cts(CJS)
- Otherwise:
.tsbecomes.mjs/.d.mts(ESM) and.js/.d.ts(CJS)
Similarly, all relativeimport/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 to
tshy—tshywas designed to enable dual-package builds powered by thetsccompiler. To make this work, it relies on a specific file structure and the creation of temporarypackage.jsonfiles to accommodate the various idiosyncrasies of Node.js module resolution. It also requires the use of separatedist/esmanddist/cjsbuild 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 to
tshy—tshyforces you to use.jsimports 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 yourpackage.json when you runzshy. 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:
importmodfrom"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 canimport CommonJS, but CommonJS files can'trequire 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—tshygenerates independent (but identical).d.tsfiles indist/esmanddist/cjs. This can causeExcessively 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.
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.
CJS interop transform — When a file contains a singleexport default ... andno named exports...
functionhello(){console.log('hello');}exportdefaulthello;
...the built.cjs code will assign the exported value directly tomodule.exports:
functionhello(){console.log('hello');}exports.default=hello;module.exports=exports.default;
...and the associated.d.cts files will useexport = syntax:
declarefunctionhello():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:
export= ...
...the generatedESM build will transpile to the following syntax:
exportdefault ...
Yes! This is one of the key reasonszshy was originally developed. Many environments don't supportpackage.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. Fortunatelyzshy unlocks a workaround I call aflat build:
Remove
"type": "module"from yourpackage.json(if present)Set
outDir: "."in yourtsconfig.jsonConfigure
"exclude"inpackage.jsonto 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.
Yes. If you prefer to manage yourpackage.json fields manually, you can preventzshy from making any changes by setting thenoEdit option totrue in yourpackage.json#/zshy config.
{"zshy": {"exports":"./src/index.ts","noEdit":true }}WhennoEdit is enabled,zshy will build your files but will not write topackage.json. You will be responsible for populating the"exports","bin","main","module", and"types" fields yourself.
Not really. It usestsc to typecheck your codebase, which is a lot slower than using a bundler that strips types. That said:
- Youshould be type checking your code during builds
- TypeScript isabout to get 10x faster
The DX ofzshy was heavily inspired bytshy by@isaacs, particularly its declarative entrypoint map and auto-updating ofpackage.json#/exports. It proved that there's a modern way to transpile libraries using puretsc (and variouspackage.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 oftshy's DX while using the Compiler API to relax these constraints and provide a more "batteries included" experience.
About
🐒 Bundler-free build tool for TypeScript libraries. Powered by tsc. WITH DEV MODE
Resources
License
Code of conduct
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Releases
Sponsor this project
Uh oh!
There was an error while loading.Please reload this page.
Packages0
Languages
- TypeScript98.8%
- Other1.2%