Why I Don't Like Enums
Enums in TypeScript can be confusing, with differences between numeric and string enums causing unexpected behaviors.
In this guide, we'll go through every single step you need to take to publish a package to npm.
在本指南中,我们将介绍将包发布到 npm 所需采取的每一个步骤。
This is not a minimal guide. We'll be setting up a fully production-ready package from an empty directory. This will include:
这不是一个最低限度的指南。我们将从一个空目录设置一个完全可用于生产的包。这将包括:
If you want to see the finished product, check out this demo repo.
如果您想查看成品,请查看此 demo repo。
If you prefer video content, I've created a video walkthrough of this guide:
如果您更喜欢视频内容,我为本指南制作了一个视频演练:
In this section, we'll create a new git repository, set up a .gitignore
, create an initial commit, create a new repository on GitHub, and push our code to GitHub.
在本节中,我们将创建一个新的 git 存储库,设置 .gitignore
,创建初始提交,在 GitHub 上创建一个新存储库,并将我们的代码推送到 GitHub。
Run the following command to initialize a new git repository:
运行以下命令以初始化新的 git 存储库。
git init
.gitignore
.gitignore
Create a .gitignore
file in the root of your project and add the following:
在项目的根目录中创建一个 .gitignore
文件,并添加以下内容:
node_modules
Run the following command to create an initial commit:
运行以下命令以创建初始提交:
git add .
git commit -m "Initial commit"
Using the GitHub CLI, run the following command to create a new repository. I've chosen the name tt-package-demo
for this example:
使用 GitHub CLI,运行以下命令以创建新存储库。在本例中,我选择了名称 tt-package-demo
:
gh repo create tt-package-demo --source=. --public
Run the following command to push your code to GitHub:
运行以下命令,将代码推送到 GitHub:
git push --set-upstream origin main
package.json
package.json
In this section, we'll create a package.json
file, add a license
field, create a LICENSE
file, and add a README.md
file.
在本节中,我们将创建一个 package.json
文件,添加一个 license
字段,创建一个 LICENSE
文件,并添加一个 README.md
文件。
package.json
filepackage.json
文件Create a package.json
file with these values:
使用以下值创建 package.json
文件:
{
"name": "tt-package-demo",
"version": "1.0.0",
"description": "A demo package for Total TypeScript",
"keywords": ["demo", "typescript"],
"homepage": "https://github.com/mattpocock/tt-package-demo",
"bugs": {
"url": "https://github.com/mattpocock/tt-package-demo/issues"
},
"author": "Matt Pocock <team@totaltypescript.com> (https://totaltypescript.com)",
"repository": {
"type": "git",
"url": "git+https://github.com/mattpocock/tt-package-demo.git"
},
"files": ["dist"],
"type": "module"
}
name
is the name by which people will install your package. It must be unique on npm. You can create organization scopes (such as @total-typescript/demo
) for free, these can help make it unique.name
是用户安装包时所依据的名称。它在 npm 上必须是唯一的。您可以免费创建组织范围(例如 @total-typescript/demo
),这些可以帮助使其独一无二。version
is the version of your package. It should follow semantic versioning: the 0.0.1
format. Each time you publish a new version, you should increment this number.version
是包的版本。它应遵循语义版本控制:0.0.1
格式。每次发布新版本时,您都应该增加此数字。description
and keywords
are short descriptions of your package. They're listed in searches in the npm registry.description
和 keywords
是软件包的简短描述。它们在 npm 注册表的搜索中列出。homepage
is the URL of your package's homepage. The GitHub repo is a good default, or a docs site if you have one.homepage
是包的 homepage 的 URL。GitHub 存储库是一个很好的默认版本,或者如果你有文档站点,则它是一个文档站点。bugs
is the URL where people can report issues with your package.bugs
是用户可以报告包问题的 URL。author
is you! You can add optionally add your email and website. If you have multiple contributors, you can specify them as an array of contributors
with the same formatting.作者
就是你!您可以选择添加您的电子邮件和网站。如果您有多个贡献者,则可以将它们指定为具有相同格式的贡献者
数组。repository
is the URL of your package's repository. This creates a link on the npm registry to your GitHub repo.repository
是包的 repository 的 URL。这会在 npm 注册表上创建一个指向 GitHub 存储库的链接。files
is an array of files that should be included when people install your package. In this case, we're including the dist
folder. README.md
, package.json
and LICENSE
are included by default.files
是人们安装您的软件包时应该包含的文件数组。在本例中,我们将包括 dist
文件夹。默认情况下,包括 README.md
、package.json
和 LICENSE
。type
is set to module
to indicate that your package uses ECMAScript modules, not CommonJS modules.type
设置为 module
以指示您的软件包使用 ECMAScript 模块,而不是 CommonJS 模块。license
fieldlicense
字段Add a license
field to your package.json
. Choose a license here. I've chosen MIT.
将 license
字段添加到您的 package.json
。在此处选择许可证。我选择了 MIT。
{
"license": "MIT"
}
LICENSE
fileLICENSE
文件Create a file called LICENSE
(no extension) containing the text of your license. For MIT, this is:
创建一个名为 LICENSE
(无扩展名) 的文件,其中包含您的许可证文本。对于 MIT,这是:
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Change the [year]
and [fullname]
placeholders to the current year and your name.
将 [year]
和 [fullname]
占位符更改为当前年份和您的姓名。
README.md
fileREADME.md
文件Create a README.md
file with a description of your package. Here's an example:
创建一个包含包描述的 README.md
文件。下面是一个示例:
**tt-package-demo**
A demo package for Total TypeScript.
This will be shown on the npm registry when people view your package.
当人们查看您的包时,这将显示在 npm 注册表中。
In this section, we'll install TypeScript, set up a tsconfig.json
, create a source file, create an index file, set up a build
script, run our build, add dist
to .gitignore
, set up a ci
script, and configure our tsconfig.json
for the DOM.
在本节中,我们将安装 TypeScript,设置 tsconfig.json
,创建源文件,创建索引文件,设置构建
脚本,运行构建,将 dist
添加到 .gitignore
,设置 ci
脚本,并为 DOM 配置 tsconfig.json
。
Run the following command to install TypeScript:
执行以下命令安装 TypeScript。
npm install --save-dev typescript
We add --save-dev
to install TypeScript as a development dependency. This means it won't be included when people install your package.
我们添加 --save-dev
来安装 TypeScript 作为开发依赖项。这意味着当用户安装您的软件包时,它不会包含在内。
tsconfig.json
tsconfig.json
Create a tsconfig.json
with the following values:
使用以下值创建tsconfig.json
:
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
/* If transpiling with TypeScript: */
"module": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
/* AND if you're building for a library: */
"declaration": true,
/* AND if you're building for a library in a monorepo: */
"declarationMap": true
}
}
These options are explained in detail in my TSConfig Cheat Sheet.
这些选项在我的 TSConfig Cheat Sheet 中有详细说明。
tsconfig.json
for the DOMtsconfig.json
If your code runs in the DOM (i.e. requires access to document
, window
, or localStorage
etc), skip this step.
如果您的代码在 DOM 中运行(即需要访问 document
、window
或 localStorage
等),请跳过此步骤。
If your code doesn't require access to DOM API's, add the following to your tsconfig.json
:
{
"compilerOptions": {
// ...other options
"lib": ["es2022"]
}
}
This prevents the DOM typings from being available in your code.
If you're not sure, skip this step.
Create a src/utils.ts
file with the following content:
export const add = (a: number, b: number) => a + b;
Create a src/index.ts
file with the following content:
export { add } from "./utils.js";
The .js
extension will look odd. This article explains more.
build
scriptAdd a scripts
object to your package.json
with the following content:
{
"scripts": {
"build": "tsc"
}
}
This will compile your TypeScript code to JavaScript.
Run the following command to compile your TypeScript code:
npm run build
This will create a dist
folder with your compiled JavaScript code.
dist
to .gitignore
Add the dist
folder to your .gitignore
file:
dist
This will prevent your compiled code from being included in your git repository.
ci
scriptAdd a ci
script to your package.json
with the following content:
{
"scripts": {
"ci": "npm run build"
}
}
This gives us a quick shortcut for running all required operations on CI.
In this section, we'll install Prettier, set up a .prettierrc
, set up a format
script, run the format
script, set up a check-format
script, add the check-format
script to our CI
script, and run the CI
script.
Prettier is a code formatter that automatically formats your code to a consistent style. This makes your code easier to read and maintain.
Run the following command to install Prettier:
npm install --save-dev prettier
.prettierrc
Create a .prettierrc
file with the following content:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}
You can add more options to this file to customize Prettier's behavior. You can find a full list of options here.
format
scriptAdd a format
script to your package.json
with the following content:
{
"scripts": {
"format": "prettier --write ."
}
}
This will format all files in your project using Prettier.
format
scriptRun the following command to format all files in your project:
npm run format
You might notice some files change. Commit them with:
git add .
git commit -m "Format code with Prettier"
check-format
scriptAdd a check-format
script to your package.json
with the following content:
{
"scripts": {
"check-format": "prettier --check ."
}
}
This will check if all files in your project are formatted correctly.
CI
scriptAdd the check-format
script to your ci
script in your package.json
:
{
"scripts": {
"ci": "npm run build && npm run check-format"
}
}
This will run the check-format
script as part of your CI process.
exports
, main
and @arethetypeswrong/cli
In this section, we'll install @arethetypeswrong/cli
, set up a check-exports
script, run the check-exports
script, set up a main
field, run the check-exports
script again, set up a ci
script, and run the ci
script.
@arethetypeswrong/cli
is a tool that checks if your package exports are correct. This is important because these are easy to get wrong, and can cause issues for people using your package.
@arethetypeswrong/cli
Run the following command to install @arethetypeswrong/cli
:
npm install --save-dev @arethetypeswrong/cli
check-exports
scriptAdd a check-exports
script to your package.json
with the following content:
{
"scripts": {
"check-exports": "attw --pack ."
}
}
This will check if all exports from your package are correct.
check-exports
scriptRun the following command to check if all exports from your package are correct:
npm run check-exports
You should notice various errors:
┌───────────────────┬──────────────────────┐
│ │ "tt-package-demo" │
├───────────────────┼──────────────────────┤
│ node10 │ 💀 Resolution failed │
├───────────────────┼──────────────────────┤
│ node16 (from CJS) │ 💀 Resolution failed │
├───────────────────┼──────────────────────┤
│ node16 (from ESM) │ 💀 Resolution failed │
├───────────────────┼──────────────────────┤
│ bundler │ 💀 Resolution failed │
└───────────────────┴──────────────────────┘
This indicates that no version of Node, or any bundler, can use our package.
Let's fix this.
main
Add a main
field to your package.json
with the following content:
{
"main": "dist/index.js"
}
This tells Node where to find the entry point of your package.
check-exports
againRun the following command to check if all exports from your package are correct:
npm run check-exports
You should notice only one warning:
┌───────────────────┬──────────────────────────────┐
│ │ "tt-package-demo" │
├───────────────────┼──────────────────────────────┤
│ node10 │ 🟢 │
├───────────────────┼──────────────────────────────┤
│ node16 (from CJS) │ ⚠️ ESM (dynamic import only) │
├───────────────────┼──────────────────────────────┤
│ node16 (from ESM) │ 🟢 (ESM) │
├───────────────────┼──────────────────────────────┤
│ bundler │ 🟢 │
└───────────────────┴──────────────────────────────┘
This is telling us that our package is compatible with systems running ESM. People using CJS (often in legacy systems) will need to import it using a dynamic import.
If you don't want to support CJS (which I recommend), change the check-exports script to:
{
"scripts": {
"check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm"
}
}
Now, running check-exports
will show everything as green:
┌───────────────────┬───────────────────┐
│ │ "tt-package-demo" │
├───────────────────┼───────────────────┤
│ node10 │ 🟢 │
├───────────────────┼───────────────────┤
│ node16 (from CJS) │ 🟢 (ESM) │
├───────────────────┼───────────────────┤
│ node16 (from ESM) │ 🟢 (ESM) │
├───────────────────┼───────────────────┤
│ bundler │ 🟢 │
└───────────────────┴───────────────────┘
If you prefer to dual publish CJS and ESM, skip this step.
CI
scriptAdd the check-exports
script to your ci
script in your package.json
:
{
"scripts": {
"ci": "npm run build && npm run check-format && npm run check-exports"
}
}
tsup
to Dual PublishIf you want to publish both CJS and ESM code, you can use tsup
. This is a tool built on top of esbuild
that compiles your TypeScript code into both formats.
My personal recommendation would be to skip this step, and only ship ES Modules. This makes your setup significantly simpler, and avoids many of the pitfalls of dual publishing, like Dual Package Hazard.
But if you want to, go ahead.
tsup
Run the following command to install tsup
:
npm install --save-dev tsup
tsup.config.ts
fileCreate a tsup.config.ts
file with the following content:
import { defineConfig } from "tsup";
export default defineConfig({
entryPoints: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
outDir: "dist",
clean: true,
});
entryPoints
is an array of entry points for your package. In this case, we're using src/index.ts
.format
is an array of formats to output. We're using cjs
(CommonJS) and esm
(ECMAScript modules).dts
is a boolean that tells tsup
to generate declaration files.outDir
is the output directory for the compiled code.clean
tells tsup
to clean the output directory before building.build
scriptChange the build
script in your package.json
to the following:
{
"scripts": {
"build": "tsup"
}
}
We'll now be running tsup
to compile our code instead of tsc
.
exports
fieldAdd an exports
field to your package.json
with the following content:
{
"exports": {
"./package.json": "./package.json",
".": {
"import": "./dist/index.js",
"default": "./dist/index.cjs"
}
}
}
The exports
field tells programs consuming your package how to find the CJS and ESM versions of your package. In this case, we're pointing folks using import
to dist/index.js
and folks using require
to dist/index.cjs
.
It's also recommended to add ./package.json
to the exports
field. This is because certain tools need easy access to your package.json
file.
check-exports
againRun the following command to check if all exports from your package are correct:
npm run check-exports
Now, everything is green:
┌───────────────────┬───────────────────┐
│ │ "tt-package-demo" │
├───────────────────┼───────────────────┤
│ node10 │ 🟢 │
├───────────────────┼───────────────────┤
│ node16 (from CJS) │ 🟢 (CJS) │
├───────────────────┼───────────────────┤
│ node16 (from ESM) │ 🟢 (ESM) │
├───────────────────┼───────────────────┤
│ bundler │ 🟢 │
└───────────────────┴───────────────────┘
We're no longer running tsc
to compile our code. And tsup
doesn't actually check our code for errors - it just turns it into JavaScript.
This means that our ci
script won't error if we have TypeScript errors in our code. Eek.
Let's fix this.
noEmit
to tsconfig.json
Add a noEmit
field to your tsconfig.json
:
{
"compilerOptions": {
// ...other options
"noEmit": true
}
}
tsconfig.json
Remove the following fields from your tsconfig.json
:
outDir
rootDir
sourceMap
declaration
declarationMap
They are no longer needed in our new 'linting' setup.
module
to Preserve
Optionally, you can now change module
to Preserve
in your tsconfig.json
:
{
"compilerOptions": {
// ...other options
"module": "Preserve"
}
}
This means you'll no longer need to import your files with .js
extensions. This means that index.ts
can look like this instead:
export * from "./utils";
lint
scriptAdd a lint
script to your package.json
with the following content:
{
"scripts": {
"lint": "tsc"
}
}
This will run TypeScript as a linter.
lint
to your ci
scriptAdd the lint
script to your ci
script in your package.json
:
{
"scripts": {
"ci": "npm run build && npm run check-format && npm run check-exports && npm run lint"
}
}
Now, we'll get TypeScript errors as part of our CI process.
In this section, we'll install vitest
, create a test, set up a test
script, run the test
script, set up a dev
script, and add the test
script to our CI
script.
vitest
is a modern test runner for ESM and TypeScript. It's like Jest, but better.
vitest
Run the following command to install vitest
:
npm install --save-dev vitest
Create a src/utils.test.ts
file with the following content:
import { add } from "./utils.js";
import { test, expect } from "vitest";
test("add", () => {
expect(add(1, 2)).toBe(3);
});
This is a simple test that checks if the hello
function returns the correct value.
test
scriptAdd a test
script to your package.json
with the following content:
{
"scripts": {
"test": "vitest run"
}
}
vitest run
runs all tests in your project once, without watching.
test
scriptRun the following command to run your tests:
npm run test
You should see the following output:
✓ src/utils.test.ts (1)
✓ hello
Test Files 1 passed (1)
Tests 1 passed (1)
This indicates that your test passed successfully.
dev
scriptA common workflow is to run your tests in watch mode while developing. Add a dev
script to your package.json
with the following content:
{
"scripts": {
"dev": "vitest"
}
}
This will run your tests in watch mode.
CI
scriptAdd the test
script to your ci
script in your package.json
:
{
"scripts": {
"ci": "npm run build && npm run check-format && npm run check-exports && npm run lint && npm run test"
}
}
In this section, we'll create a GitHub Actions workflow that runs our CI process on every commit and pull request.
This is a crucial step in ensuring that our package is always in a working state.
Create a .github/workflows/ci.yml
file with the following content:
name: CI
on:
pull_request:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm install
- name: Run CI
run: npm run ci
This file is what GitHub uses as its instructions for running your CI process.
name
is the name of the workflow.on
specifies when the workflow should run. In this case, it runs on pull requests and pushes to the main
branch.concurrency
prevents multiple instances of the workflow from running at the same time, using cancel-in-progress
to cancel any existing runs.jobs
is a set of jobs to run. In this case, we have one job called ci
.actions/checkout@v4
checks out the code from the repository.actions/setup-node@v4
sets up Node.js and npm.npm install
installs the project's dependencies.npm run ci
runs the project's CI script.If any part of our CI process fails, the workflow will fail and GitHub will let us know by showing a red cross next to our commit.
Push your changes to GitHub and check the Actions tab in your repository. You should see your workflow running.
This will give us a warning on every commit made, and every PR made to the repository.
In this section, we'll install @changesets/cli
, initialize Changesets, make changeset releases public, set commit
to true
, set up a local-release
script, add a changeset, commit your changes, run the local-release
script, and finally see your package on npm.
Changesets is a tool that helps you version and publish your package. It's an incredible tool that I recommend to anyone publishing packages to npm.
@changesets/cli
Run the following command to initialise Changesets:
npm install --save-dev @changesets/cli
Run the following command to initialize Changesets:
npx changeset init
This will create a .changeset
folder in your project, containing a config.json
file. This is also where your changesets will live.
In .changeset/config.json
, change the access
field to public
:
// .changeset/config.json
{
"access": "public"
}
Without changing this field, changesets
won't publish your package to npm.
commit
to true
:In .changeset/config.json
, change the commit
field to true
:
// .changeset/config.json
{
"commit": true
}
This will commit the changeset to your repository after versioning.
local-release
scriptAdd a local-release
script to your package.json
with the following content:
{
"scripts": {
"local-release": "changeset version && changeset publish"
}
}
This script will run your CI process and then publish your package to npm. This will be the command you run when you want to release a new version of your package from your local machine.
prepublishOnly
Add a prepublishOnly
script to your package.json
with the following content:
{
"scripts": {
"prepublishOnly": "npm run ci"
}
}
This will automatically run your CI process before publishing your package to npm.
This is useful to separate from the local-release
script in case a user accidentally runs npm publish
without running local-release
. Thanks to Jordan Harband for the suggestion!
Run the following command to add a changeset:
npx changeset
This will open an interactive prompt where you can add a changeset. Changesets are a way to group changes together and give them a version number.
Mark this release as a patch
release, and give it a description like "Initial release".
This will create a new file in the .changeset
folder with the changeset.
Commit your changes to your repository:
git add .
git commit -m "Prepare for initial release"
local-release
scriptRun the following command to release your package:
npm run local-release
This will run your CI process, version your package, and publish it to npm.
It will have created a CHANGELOG.md
file in your repository, detailing the changes in this release. This will be updated each time you release.
Go to:
http://npmjs.com/package/<your package name>
You should see your package there! You've done it! You've published to npm!
You now have a fully set up package. You've set up:
@arethetypeswrong/cli
, which checks that your package exports are correcttsup
, which compiles your TypeScript code to JavaScriptvitest
, which runs your testsFor further reading, I'd recommend setting up the Changesets GitHub action and PR bot to automatically recommend contributors add changesets to their PR's. They are both phenomenal.
And if you've got any more questions, let me know!
Stay up-to-date on the latest news and updates from the world of TypeScript.
Share this article with your friends
Enums in TypeScript can be confusing, with differences between numeric and string enums causing unexpected behaviors.
Is TypeScript just a linter? No, but yes.
It's a massive ship day. We're launching a free TypeScript book, new course, giveaway, price cut, and sale.
Learn why the order you specify object properties in TypeScript matters and how it can affect type inference in your functions.
Learn how to use corepack
to configure package managers in Node.js projects, ensuring you always use the correct one.
Learn how to strongly type process.env in TypeScript by either augmenting global type or validating it at runtime with t3-env.