Skip to content

colinhacks/zshy

Repository files navigation

πŸ’
zshy

The no-bundler build tool for TypeScript libraries. Powered by tsc.
by @colinhacks


License npm stars




What is zshy?

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 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
  • πŸ“‚ 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



Quickstart


1. Install zshy as a dev dependency:

npm install --save-dev zshy yarn add --dev zshy pnpm add --save-dev zshy

2. Specify your entrypoint(s) in package.json#zshy:

{ "name": "my-pkg", "version": "1.0.0", + "zshy": { + "exports" : { + ".": "./src/index.ts" + } + } }

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 β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•§β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• β†’ 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

{ // ... "scripts": { + "build": "zshy" } }

Then, to run a build:

$ npm run build


How it works

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 and tsconfig.json)


Usage


Flags

$ 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"

Subpaths and wildcards

Multi-entrypoint packages can specify subpaths or wildcard exports with package.json#/zshy/exports:

{ "name": "my-pkg", "version": "1.0.0", "zshy": { "exports": { ".": "./src/index.ts", // root entrypoints "./utils": "./src/utils.ts", // subpath "./plugins/*": "./src/plugins/*" // wildcards } } }
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/*" + } + } }

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" // πŸ‘ˆ 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 + } }



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/*" // 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.


Does zshy respect my tsconfig.json compiler options?

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

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)

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 β€” tshy requires you to put your source in a ./src directory, and always builds to ./dist/esm and ./dist/cjs.


What files does zshy create?

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 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.


How does extension rewriting work?

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 β€” 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.


Can I use extension-less imports?

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 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.


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.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/*" + } + } }

Why .d.cts for "types"?

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 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.


Can it support React Native or non-Node.js environments?

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:

    { // ... "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.


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:

  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.

About

πŸ’ Bundler-free build tool for TypeScript libraries. Powered by tsc.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published