colinhacks/zshy
colinhacks/zshy is a TypeScript project with 1.1k stars. π Bundler-free build tool for TypeScript libraries. Powered by tsc.
Snapshot summary built from the project's own GitHub metadata β there's no written TopGit review yet. The page will update automatically when a full review is published.
TopGit writes full reviews for the most-starred, most-requested repositories. This page is a snapshot until then β see the READ ME tab for the original README in full.
Snapshot
Read our review methodology β
Top contributors
Show top contributors
π
zshy
The no-bundler build tool for TypeScript libraries. Powered by tsc.
by @colinhacks
What is 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.jsonandtsconfig.json - π Declarative entrypoint map β Specify your TypeScript entrypoints in
package.json#/zshy - π€ Auto-generated
"exports"β Writes"exports"map directly into yourpackage.jsonandjsr.json - π§± ES modules β Builds standard ESM, which
importand (on Node.js 20.19+ and 22.12+)requirecan both load - π Unopinionated β Use any file structure or import extension syntax you like
- π¦ Asset handling β Non-JS assets are copied to the output directory
- βοΈ Supports
.tsxβ Compiles to.jsper yourtsconfig.json#/jsx*settings - π CLI-friendly β First-class
"bin"support - π Blazing fast β Just kidding, it's slow. But it's worth it
Quickstart
1. Install zshy as a dev dependency:
npm install --save-dev zshy
yarn add --dev zshy
pnpm add --save-dev zshy
zshy builds with your project's own typescript, which must be 7.1 or later. While 7.1 is in beta, install it as typescript@beta. zshy builds ES modules only, so your package needs "type": "module". To build with TypeScript 5 or 6, or to publish CommonJS, use [email protected].
2. Specify your entrypoint(s) in package.json#zshy:
Single entrypoint:
// package.json
{
"name": "my-pkg",
"version": "1.0.0",
"type": "module",
+ "zshy": "./src/index.ts"
}
Multiple entrypoints:
// package.json
{
"name": "my-pkg",
"version": "1.0.0",
"type": "module",
+ "zshy": {
+ "exports": {
+ ".": "./src/index.ts",
+ "./utils": "./src/utils.ts",
+ "./plugins/*": "./src/plugins/*", // wildcard
+ "./components/**/*": "./src/components/**/*" // deep wildcard
+ }
+ }
}
3. Run a build
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 β
ββββββββββββ§βββββββββββββββββ
β Building...
β 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
How it works
Node.js loads an ES module's relative imports by exact path, so from "./util" has to reach your users as from "./util.js". Vanilla tsc won't write that for you: under "moduleResolution": "bundler" it emits the specifier exactly as you wrote it, and under "nodenext" it makes you write .js throughout your source. This is the limitation that pushes library authors toward bundlers or bundler-powered tools like tsup, tsdown, or unbuild...
...until now! zshy builds with tsc through TypeScript's compiler API, then rewrites every relative import in the emitted output to the file it names:
| Original path | Result |
|---|---|
from "./util" | from "./util.js" |
from "./util.ts" | from "./util.js" |
from "./util.js" | from "./util.js" |
from "./utils" (a directory with index.ts) | from "./utils/index.js" |
Each .ts file becomes a .js file and its .d.ts declarations:
$ tree .
βββ package.json
βββ src
β βββ index.ts
βββ dist # generated
βββ index.js
βββ index.d.ts
Finally, zshy automatically writes "exports" into your package.json:
{
// ...
"zshy": {
"exports": "./src/index.ts"
},
+ "exports": { // auto-generated by zshy
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ }
}
The result is a tool that I consider to be the "holy grail" of TypeScript library build tools:
- emits standard ES modules whose imports Node.js can resolve as written
- type checks your code
- leverages
tscfor gold-standard transpilation - doesn't require a bundler
- doesn't require another config file (just
package.jsonandtsconfig.json)
Usage
Flags
$ npx 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"
Subpaths and wildcards
Multi-entrypoint packages can specify subpaths or wildcard exports in package.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 β
ββββββββββββ§βββββββββββββββββ
β Building...
β Updating package.json exports...
β Build complete! β
And the generated "exports" map will look like this:
// package.json
{
// ...
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./utils": {
+ "types": "./dist/utils.d.ts",
+ "default": "./dist/utils.js"
+ },
+ "./plugins/*": {
+ "types": "./dist/src/plugins/*",
+ "default": "./dist/src/plugins/*"
+ }
+ }
}
Building CLIs ("bin" support)
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"
}
}
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.js" // CLI entrypoint
+ }
}
Multiple CLIs
For packages that expose multiple CLI tools, "bin" can also be an object mapping each 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.js",
"other": "./dist/other.js"
}
}
Be sure to include a shebang as the first line of your CLI entrypoint file:
#!/usr/bin/env node
// CLI code here
CommonJS
zshy builds ES modules only. Node.js 20.19+ and 22.12+ can require() an ES module, so CommonJS code can still load your package, unless it uses top-level await.
To publish CommonJS output, use [email protected], which builds ESM and CommonJS side by side (with TypeScript 5 or 6). Coming from it:
"cjs": falseis still accepted, and changes nothing."cjs": true,"sealCjsExports", and conditions set to"cjs"are errors.zshypoints"main"at the ESM entrypoint, replacing the.cjspath a 0.8 build wrote there.
Custom conditions
To specify custom conditions in your export maps, specify a "conditions" map. Each condition name must correspond to one of "src" | "esm".
{
"zshy": {
"exports": {
".": "./src/index.ts"
},
+ "conditions": {
+ "my-src-condition": "src",
+ "my-esm-condition": "esm"
+ }
}
}
With this addition, zshy will add both conditions to the generated "exports" map:
// package.json
{
"exports": {
".": {
+ "my-src-condition": "./src/index.ts",
+ "my-esm-condition": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}
JSR
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.
FAQ for nerds
How does zshy resolve entrypoints?
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/*", // 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
"./" - All values should be relative paths to source files (resolved relative to the
package.jsonfile)
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) - 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 β Since zshy computes an exact set of resolved entrypoints, your "files", "include", and "exclude" settings in tsconfig.json are ignored during the build.
Does zshy respect my tsconfig.json compiler options?
Yes! With some strategic overrides:
module: Overridden to"esnext"moduleResolution: Overridden to"bundler", so extensionless imports type-check (zshythen adds the extension)declaration/noEmit/emitDeclarationOnly: Overridden to ensure proper outputverbatimModuleSyntax: Set tofalse, so a type-only import written withoutimport typeis still erasedesModuleInterop: 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, 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)
Which versions of TypeScript does zshy support?
TypeScript 7.1 and later. zshy builds with the typescript your project has installed, through the API of TypeScript's native compiler, and 7.1 is the first release where that API is stable. While 7.1 is in beta, install it as typescript@beta.
To build with TypeScript 5 or 6, use [email protected], the last release built on the classic Compiler API.
Coming from [email protected], the ESM output should be unchanged (CommonJS output is gone: see CommonJS). A few declarations come out of TypeScript 7 in a different but equivalent shape, and source maps differ in detail. TypeScript 7 also keeps the defaults TypeScript 6 changed, and zshy checks your code with them. Two that commonly surface:
typesnow defaults to[], so Node.js globals likeconsoleandprocessneed"types": ["node"].- Side-effect imports are checked, so
import "./styles.css"reports TS2882 unless you set"noUncheckedSideEffectImports": false.zshystill copies the asset.
TypeScript 7 also removes baseUrl: spell paths targets relative to the tsconfig instead, e.g. "./src/*".
Do I need to use a specific file structure?
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βtshyrequires you to put your source in a./srcdirectory, and always builds to./dist/esmand./dist/cjs.
What files does zshy create?
A .js file and its .d.ts declarations for each .ts or .tsx source, plus source maps if your tsconfig.json asks for them:
$ tree .
βββ package.json
βββ src
β βββ index.ts
βββ dist
βββ index.js
βββ index.d.ts
A .mts source becomes .mjs/.d.mts, and a .cts source becomes .cjs/.d.cts: TypeScript's own output for those extensions, which zshy leaves as it is.
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 with Module Federation.
How does import rewriting work?
zshy rewrites relative import/export specifiers in the JavaScript and declarations that tsc emits, so each one names a real file:
| Original path | Result |
|---|---|
from "./util" | from "./util.js" |
from "./util.ts" | from "./util.js" |
from "./util.js" | from "./util.js" |
from "./utils" (a directory with index.ts) | from "./utils/index.js" |
The TypeScript 7 API has no transformer hook, so zshy works on what tsc emits: each relative specifier is found by its position in the parsed output and rewritten there. A string that merely looks like a path is never touched.
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.
Can I use extension-less imports?
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.
What about package.json#/exports?
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.ts",
+ "default": "./dist/index.js"
+ },
+ "./utils": {
+ "types": "./dist/utils.d.ts",
+ "default": "./dist/utils.js"
+ },
+ "./plugins/*": {
+ "types": "./dist/src/plugins/*",
+ "default": "./dist/src/plugins/*"
+ }
+ }
}
Can it support React Native or non-Node.js environments?
Yes! This is one of the key reasons zshy was originally developed. Some environments ignore package.json#/exports β the React Native Metro bundler does by default β which causes issues for packages that want to use subpath imports to structure their package. Fortunately zshy unlocks a workaround I call a flat build:
-
Set
outDir: "."in yourtsconfig.json -
Keep your TypeScript sources out of the published package. If
package.jsonhas no"files",zshysets it to the built extensions for you:{ // ... "files": ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.d.ts", "**/*.d.mts", "**/*.d.cts"] }
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.
Can I prevent zshy from modifying my package.json/jsr.json?
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.
{
"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.
Is it fast?
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
zshybuilds on TypeScript 7, the 10x faster native port
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.
Related repositories
Master programming by recreating your favorite technologies from scratch.
A curated meta-list of curated lists organized by technology domain. The repository acts as a directory pointing to hundreds of specialized awesome lists covering programming languages, platforms, frameworks, and tooling. All content is community-contributed under the CC0 public domain dedication.
Public APIs is a community-curated GitHub repository listing free, publicly accessible APIs across a wide range of categories, with auth type, HTTPS, and CORS noted for each entry. It's a browsable reference, not a library to install.
freeCodeCamp is a free, self-paced curriculum for learning to code, published as open source at freeCodeCamp/freeCodeCamp. It's a 501(c)(3) nonprofit funded by donor support, structured around six certifications in its Full-Stack Developer Curriculum, each gated by required projects instead of open-book quizzes. The repository also carries beta language certifications for developers, interview-prep resources, and the code that runs the live freecodecamp.org platform.
Quick answers
Does colinhacks/zshy have a project website?
No homepage URL was recorded for colinhacks/zshy in TopGit's last sync. The README tab above frequently contains screenshots and demo links, or check the repository description on GitHub.
Is colinhacks/zshy open source?
Yes β colinhacks/zshy ships under the MIT license, which makes its source code freely readable (and, depending on license terms, forkable and reusable). Source: github.com/colinhacks/zshy.
What is colinhacks/zshy?
colinhacks/zshy (colinhacks/zshy) is a TypeScript project on GitHub. From the project's own README: π Bundler-free build tool for TypeScript libraries. Powered by tsc.
What license does colinhacks/zshy use?
colinhacks/zshy is released under the MIT license. Always verify the LICENSE file directly on GitHub for the authoritative terms β license strings can be edited out of sync with a project's actual stance.
Where do I read more about colinhacks/zshy?
This TopGit page is a snapshot β the READ ME tab shows the project's own README content (links stripped, images preserved). The GitHub repository at github.com/colinhacks/zshy is the definitive source.
Read full README in the tab above.
Want a second opinion on zshy?
Ask an AI that can read this page β one click and you get its take on zshy.