SkillAgentSearch skills...

Tshy

No description available

Install / Use

/learn @isaacs/Tshy
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

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.json file 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.json file, 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" 
View on GitHub
GitHub Stars1.0k
CategoryDevelopment
Updated2d ago
Forks23

Languages

JavaScript

Security Score

90/100

Audited on Mar 30, 2026

No findings