Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

🐒 Bundler-free build tool for TypeScript libraries. Powered by tsc.

License

NotificationsYou must be signed in to change notification settings

colinhacks/zshy

Repository files navigation

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


Licensenpmstars




What iszshy?

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 bytsc — The gold standard for TypeScript transpilation
  • 📦Bundler-free — No bundler or bundler configs involved
  • 🟦No config file — Reads from yourpackage.json andtsconfig.json
  • 📝Declarative entrypoint map — Specify your TypeScript entrypoints inpackage.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/.mjs per yourtsconfig.json#/jsx* settings
  • 🐚CLI-friendly — First-class"bin" support
  • 🐌Blazing fast — Just kidding, it's slow. Butit's worth it



Quickstart


1. Installzshy as a dev dependency:

npm install --save-dev zshyyarn add --dev zshypnpm add --save-dev zshy

2. Specify your entrypoint(s) inpackage.json#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+   }+ }}

3. Run a build

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


How it works

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 pathResult (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
  • leveragestsc for gold-standard transpilation
  • doesn't require a bundler
  • doesn't require another config file (justpackage.json andtsconfig.json)


Usage


Flags

$ npx zshy --helpUsage: zshy [options]Options:  -h, --help                        Show thishelp 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 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/*"+   }+ }}

Building CLIs ("bin" support)

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 here

ESM-only

For 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"    }  }}

Custom conditions

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



FAQ for nerds


How doeszshy resolve entrypoints?

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 thepackage.json file)

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!zshy matches 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.


Doeszshy respect mytsconfig.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 tofalse to allow multiple build formats
  • esModuleInterop: Set totrue (it's a best practice)
  • composite: Set tofalse to avoid resolution issues.zshy will 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)

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 yourtsconfig.json settings.

Comparison totshytshy requires you to put your source in a./src directory, and always builds to./dist/esm and./dist/cjs.


What files doeszshy create?

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 totshytshy 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 withModule Federation.


How does extension rewriting work?

zshy uses theTypeScript Compiler API to rewrite file extensions during thetsc 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 relativeimport/export statements are rewritten to account for the new file extensions.

Original pathResult (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 totshytshy 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.


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 totshytshy 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 aboutpackage.json#/exports?

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

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:

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 totshytshy generates independent (but identical).d.ts files indist/esm anddist/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.


Why do I see "Masquerading as CJS"?

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.


How are default exports transpiled?

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

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

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:

  1. Remove"type": "module" from yourpackage.json (if present)

  2. SetoutDir: "." in yourtsconfig.json

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


Can I preventzshy from modifying mypackage.json?

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.


Is it fast?

Not really. It usestsc to typecheck your codebase, which is a lot slower than using a bundler that strips types. That said:

  1. Youshould be type checking your code during builds
  2. TypeScript isabout to get 10x faster


Acknowledgements

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.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages


[8]ページ先頭

©2009-2025 Movatter.jp