Tshy
No description available
Install / Use
/learn @isaacs/TshyREADME
tshy - TypeScript HYbridizer
Hybrid (CommonJS/ESM) TypeScript node package builder. Write modules that Just Work in ESM and CommonJS, in easy mode.
This tool manages the exports in your package.json file, and
builds your TypeScript program using tsc 5.2+, emitting both ESM
and CommonJS variants, providing the full strength of
TypeScript’s checking for both output
formats.
[!NOTICE]
Upgrading from v3 to v4
Version 4 switches from TypeScript 5 to TypeScript 6. (Note: you can already preview TypeScript 7 by setting
"compiler": "tsgo"in your tshy options.)This may cause problems upgrading, because tshy will only create a new
tsconfig.jsonfile if the file is missing (assuming that you've put it there for a good reason), and the requirements have changed slightly.If you get tsc errors after upgrading, try deleting your
tsconfig.jsonfile, and re-running tshy.
USAGE
Install tshy:
npm i -D tshy
Put this in your package.json to use it with the default configs:
{
"files": ["dist"],
"scripts": {
"prepare": "tshy"
}
}
Put your source code in ./src.
The built files will end up in ./dist/esm (ESM) and
./dist/commonjs (CommonJS).
Your exports will be edited to reflect the correct module entry
points.
Dual Package Hazards
If you are exporting both CommonJS and ESM forms of a package, then it is possible for both versions to be loaded at run-time. However, the CommonJS build is a different module from the ESM build, and thus a different thing from the point of view of the JavaScript interpreter in Node.js.
Consider this contrived example:
// import the class from ESM
import { SomeClass } from 'module-built-by-tshy'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
// create an object using the commonjs version
function getObject() {
const { SomeClass } = require('module-built-by-tshy')
return new SomeClass()
}
const obj = getObject()
console.log(obj instanceof SomeClass) // false!!
In a real program, this might happen because one part of the code
loads the package using require() and another loads it using
import.
The Node.js documentation recommends exporting an ESM wrapper that re-exports the CommonJS code, or isolating state into a single module used by both CommonJS and ESM. While these strategies do work, they are not what tshy does.
What Does tshy Do Instead?
It builds your program twice, into two separate folders, and sets up exports. By default, the ESM and CommonJS forms live in separate universes, unaware of one another, and treats the "Dual Module Hazard" as a simple fact of life.
Which it is.
"Dual Module Hazard" is a fact of life anyway
Since the advent of npm, circa 2010, module in node have been
potentially duplicated in the dependency graph. Node's nested
node_modules resolution algorithm, added in Node 0.4, made this
even easier to leverage, and more likely to occur.
So: as a package author, you cannot safely rely on there being exactly one copy of your library loaded at run-time.
This doesn't mean you shouldn't care about it. It means that you should take it into consideration always, whether you are using a hybrid build or not.
If you need to ensure that exactly one copy of something exists at run-time, whether using a hybrid build or not, you need to guard this with a check that is not dependent on the dependency graph, such as a global variable.
const ThereCanBeOnlyOne = Symbol.for('there can be only one')
const g = globalThis as typeof globalThis & {
[ThereCanBeOnlyOne]?: Thing
}
import { Thing } from './thing.js'
g[ThereCanBeOnlyOne] ??= new Thing
export const thing = g[ThereCanBeOnlyOne]
If you find yourself doing this, it's a good idea to pause and
consider if you would be better off with a type check function or
something other than relying on instanceof. There are certainly
cases where it's unavoidable, but it can be tricky to work with.
Module Local State
There are some cases where you need something to be the same value whether loaded with CommonJS or ESM, but not necessarily unique to the entire program.
For example, say that there is some package-local set of data,
and it needs to be updated and accessible whether the user is
accessing your package via import or require.
In this case, we can use a dialect polyfill that pulls in the state module from a single dialect.
In Node, it's easy for ESM to load CommonJS, but since ESM cannot be loaded synchronously by CommonJS, I recommend putting the state in the polyfill, and having the "normal" module access it from that location.
For example:
// src/index.ts
import { state } from './state.js'
export const setValue = (key: string, value: any) => {
state[key] = value
}
export const getValue = (key: string) => state[key]
// src/state-cjs.cts
// this is the actual "thing"
export const state: Record<string, any> = {}
// src/state.ts
// this is what will end up in the esm build
// need a ts-ignore because this is a hack.
//@ts-ignore
import cjsState from '../commonjs/state.js'
export const { state } = cjsState as { state: Record<string, any> }
If you need a provide an ESM dialect that doesn't support CommonJS (eg, deno, browser, etc), then you can do this:
// src/state-deno.mts
// can't load the CJS version, so no dual package hazard
export const state: Record<string, any> = {}
See below for more on using dialect specific polyfills.
Handling Default Exports
export default is the bane of hybrid TypeScript modules.
When compiled as CommonJS, this results in creating an export
named default, which is not the same as setting
module.exports.
// esm, beautiful and clean
import foo from 'foo'
// commonjs, unnecessarily ugly and confusing
// even if you like it for some reason, it's not "the same"
const { default: foo } = require('foo')
You can tell TypeScript to do a true default export for CommonJS
by using export = <whatever>. However:
- This is not compatible with an ESM build.
- You cannot export types along with it.
In general, when publishing TypeScript packages as both CommonJS and ESM, it is a good idea to avoid default exports for any public interfaces.
- No need to polyfill anything.
- Can export types alongside the values.
However, if you are publishing something that does need to provide a default export (for example, porting a project to hybrid and/or TypeScript, and want to keep the interface consistent), you can do it with a CommonJS polyfill.
// index.ts
// the thing that gets exported for ESM
import { thing } from './main.ts'
import type { SomeType } from './main.ts'
export default thing
export type { SomeType }
// index-cjs.cts
// the polyfill for CommonJS
import * as items from './main.ts'
declare global {
namespace mything {
export type SomeType = items.SomeType
}
}
export = items.thing
Then, CommonJS users will get the appropriate thing when they
import 'mything', and can access the type via the global
namespace like mything.SomeType.
But in almost all cases, it's much simpler to just use named exports exclusively.
Very Old Module Resolution Algorithms
Before Node.js v12 and in some other old module resolution implementations the exports field is not respected, making it impossible to load CJS code with require and ESM code with import.
The entire point of tshy is to build hybrid (ESM and CJS) packages. tshy does not and never will support targeting platforms like Node.js v10 that do not respect the package.json exports field.
That said, tshy does create top-level main, module, and
types fields, which may satisfy many use cases in old
platform versions.
Do not mistake this incidental support for a promise of continued support! It is not even "best effort", it is "works by mistake". Please update your platforms!
Configuration
Mostly, this just uses opinionated convention, and so there is very little to configure.
Source must be in ./src. Builds are in ./dist/commonjs for
CommonJS and ./dist/esm for ESM.
There is very little configuration for this, but a lot of things can be configured.
exports
By default, if there is a src/index.ts file, then that will be
set as the "." export, and the package.json file will be
exported as "./package.json", because that's just convenient to
expose.
You can set other entry points by putting something like this in
your package.json file:
{
"tshy": {
"exports": {
"./foo": "./src/foo.ts",
"./bar": "./src/bar.ts",
".": "./src/something-other-than-index.ts",
"./package.json": "./package.json"
}
}
}
Any exports pointing to files in ./src will be updated to their
appropriate build target locations, like:
{
"exports": {
"./foo": {
"import": {
"types": "./dist/esm/foo.d.ts",
"default": "./dist/esm/foo.js"
},
"require": {
"types": "./dist/commonjs/foo.d.ts",
"default": "./dist/commonjs/foo.js"
}
}
}
}
Any exports that are not within ./src will not be built, and
can be anything supported by package.json exports, as they will
just be passed through as-is.
{
"tshy": {
"exports": {
".": "./src/my-built-module.ts",
"./package.json": "./package.json",
"./thing": {
"import": "./lib/thing.mjs",
"require": "./lib/thing.cjs",
"types": "./lib/thing.d.ts"
},
"./arraystyle": [
{ "import": "./no-op.js" },
{ "browser": "./browser-thing.js" },
{
"require": [
{ "types": "./using-require.d.ts" },
"./using-require.js"
]
},
{ "types": "./blah.d.ts"
