<onWebFocus />

Knowledge is only real when shared.

Publishing TypeScript

October 20, 2024

Is publishing TypeScript code directly to npm a viable option?

I've started publishing packages to npm directly as TypeScript source code, without a build step, and have had positive experiences so far. In this post, I'll show you how to do this and what you need to watch out for. I'll also discuss whether this approach could soon become a viable option for the broader developer community.

One of Bun's most important features, unlike Node.js, is its ability to run TypeScript files directly. This means that even server-side CLIs can be published as TypeScript. For regular web development, most developers now set up their projects using TypeScript.

Publishing as TypeScript

By now, most developers are writing code exclusively in TypeScript. It has matured to the point where it has become an essential tool that many can’t live without. When publishing a package to npm, people typically do two things: first, they bundle the code into a single JavaScript file, and second, they add separate type declaration files.

The bundling step is theoretically unnecessary and even counterproductive, as it results in unreadable intermediate code, which will be bundled again by any consuming application anyway.

Advantages

When you try to view the source code of a typical library with separate type declaration files by clicking on the import statement, the editor redirects you to the type declarations, where the actual source is missing, and edits have no effect. However, when the source is in TypeScript, there's no need for additional declaration files, and the editor will jump directly to the source. This can be very handy when developing your own plugins or working with ones that may still contain bugs. Editing and reading the source code in TypeScript can be much easier.

Disadvantages

Type declarations contain the compiled types, which are sometimes shorter than the source code, with inference patterns already evaluated. While TypeScript can still be a performance bottleneck in tooling, performance can be optimized by adding specific annotations for public interfaces, which usually won't have a significant impact. If you're curious, there are tools available that show how long it takes TypeScript to parse a particular library, helping you determine if optimizations are necessary for your project.

Bundler support

TypeScript source won't necessarily work with every bundler or runtime. However, since these tools are freely available, you can expect developers to update them periodically. TypeScript doesn't run in Node.js but works perfectly with its unofficial successor, Bun. Both Vite and Rsbuild support bundling TypeScript out of the box. Somewhat surprisingly, React Native's Metro bundler can also handle TypeScript dependencies. Since the default template has long been published in TypeScript, there is no reason not to publish new React Native plugins in TypeScript, which is exactly what my template, create-react-native-plugin, does. It currently doesn't work with Next.js, but that may be fixable with some configuration.

Configuration

When code is imported from a package as TypeScript, the user's tsconfig.json will be applied, rather than the configuration from the package. The biggest challenge is ensuring that the code you publish will work with arbitrary user configurations. The first and most obvious step is to write your code with strict mode enabled, in case users have it enabled as well. It's also a good idea to mention that the plugin is published as TypeScript and provide a link to your specific tsconfig.json file. Sometimes specific type dependencies, like @types/react or @types/bun, are required. You can either assume users will have them installed, document the requirement, or include them as dependencies or peerDependencies. If the code is only executed with Bun or bundled without type checking, the configuration is irrelevant and won't be applied.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "skipLibCheck": true,
    "verbatimModuleSyntax": true,
    "target": "ES2020",
    "lib": ["DOM", "ES2020"],
    "module": "Preserve",
    "noEmit": true,
    "jsx": "react-jsx"
  }
}

Here are the options I've added to the plugin configuration for zero-configuration. You can now use the template with the command bun create now zero-configuration ./my-plugin plugin-react to bootstrap your plugin.

Strict mode is the most important, though it may also be the most annoying option, as you'll need to enable it if any of your users do. Some properties are set to true simply to avoid adding unnecessary settings that could cause issues. For example, skipLibCheck won't have an effect when users enable it, as source plugins inside node_modules will still be checked, and there's no way to bypass this using exclude.

The verbatimModuleSyntax option ensures that imports without explicit type annotations won't be stripped. What users set as their target doesn't matter as long as your code doesn't use constructs outside that range. ES2020 seems like a reasonable choice, as some users might not have bundlers capable of transpiling the latest constructs. For the module setting, we're using preserve, which requires writing ESM code. This also sets moduleResolution tobundler, meaning file extensions don't need to be added to imports. However, users will need to configure module and moduleResolution to compatible values.

The jsx flag enables JSX, and it only affects how the output is emitted when using tsc without noEmit. Users can choose their preferred JSX flavor, or use preserve to delegate JSX transformation to the bundler, which is what most will do anyway.

bunx any-tsconfig --list

To simplify compatibility, I've created a plugin called any-tsconfig, which tests common options a user might have selected and identifies the ones that fail. This helps you write code with maximum compatibility in mind. Most developers stick to the default settings, but since many things are configurable, it's good to stay vigilant.

What about <JSX />

JSX source code is typically published in compiled form, tying it to a specific runtime, such as React's new auto-runtime. JSX published directly will be easier to read and edit, and the compilation will align with the end user's choice, rather than the specific approach used for publishing, making it more flexible. This method is also future-proof, as a new transpiler could introduce additional features or make changes. This, of course, also applies to TypeScript.

React features, like hooks for state management, aren't strictly tied to JSX and require either React itself or a hooks-compatible library that is aliased as react in the bundler.

Requirements

Publishing the source will also greatly simplify package configuration. While some have been publishing ESM-only packages for years, you can now confidently assume that your users are using an ESM-compatible bundler when publishing TypeScript.

{
  "name": "typescript-plugin",
  "exports": {
    ".": "./index.tsx"
  },
  "devDependencies": {
    "typescript": "^5.6.2"
  }
}

Notably absent are the main and types fields, which are no longer necessary. For the entry point, we're linking to a .tsx file that includes both TypeScript and JSX. There's no need to list typescript as a dependency, since the plugin will only work for TypeScript projects anyway. However, it can be listed as an optional peerDependency.

{
  "compilerOptions": {
    "strict": true
  }
}

If any users have strict mode enabled in their projects, the plugin must also be written in strict mode; therefore, it should always be enabled.

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount(count + 1)}>
      Increment
    </button>
  )
}

The source file itself isn't special, and this is where users will be directed when they try to navigate to the source of an import statement.

Examples

The following list includes all the plugins I have published as TypeScript so far to see if it works and what issues might arise. Once the initial hurdles I've described in this post have been overcome, I encountered almost no issues, and I can definitely recommend this approach. However, caution is advised if you're publishing to an existing user base, as this may present a breaking change that some users might be unwilling to accept.

zero-configuration
masua
overflow-scroll-fade
optica
epic-cli

Zero-configuration is a development tool for Bun that I covered in an earlier post. Masua is a fork of a masonry grid. Meanwhile, overflow-scroll fade is a modern, albeit less compatible, scroll overflow indicator that serves as a successor to indicate. Similarly, optica is a rewrite of wasser that provides responsively scaling size values for CSS-in-JS. Last but not least, epic-cli offers a variety of useful everyday commands for web development with Bun.

For React Native, I have recently switched to the plugin template create-react-native-plugin and reactigation, which is built on that template.

Is this the future?

While the future is hard to predict and often takes time to come to fruition—especially as considerations like compatibility are emphasized by most package authors—it's worth examining how long the transition from CommonJS to ES Modules took and how many are still publishing CJS fallbacks for a small user base. Since publishing TypeScript represents a significant breaking change, most developers are likely to be very hesitant to adopt it. TypeScript itself seems to be on the rise to dominate web development, and there are no notable alternatives worth mentioning. For many, the advantages probably won't outweigh the drawbacks for quite some time, and the number of developers unwilling or unable to switch from JavaScript to TypeScript may continue to be relatively significant.

Overall, I recommend adopting publishing as TypeScript when creating a new plugin without an existing user base. The most important thing is to mention this in the documentation, along with some tips, so users won't be confused when trying to figure out why certain features might not be working. If you're looking for a small challenge, you could create a plugin that strips the types from a package, allowing it to work without TypeScript.

This post was revised with ChatGPT a Large Language Model.