An example monorepo boilerplate for nodejs.
This works well, and I've been carefully maintaining it.
Just clone this! Then read and compare with README.
You'd grasp how this repo works.
Feel free to make issues for any questions or suggestions.
This project has two packages, foo
(@jjangga0214/foo
) and bar
(@jjangga0214/bar
). bar
depends on foo
.
Lerna respects and and delegates monorepo management to yarn workspace, by 'useWorkspaces': true
in lerna.json. But Lerna is still useful, as it provides utility commands for monorepo workflow (e.g. selective subpackge script execution, versioning, or package publishing).
There're' some cases module alias becomes useful.
-
A package with a deep and wide directory tree: For example, let's say
foo/src/your/very/deep/module/index.ts
imports../../../../another/deep/module/index
. In this case, absolute path from the root(e.g. alias#foo
->foo/src
) like#foo/another/deep/module/index
can be more concise and maintainable. -
Dependency not located in node_modules: This can happen in monorepo. For instance,
@jjangga0214/bar
depends on@jjangga0214/foo
, but the dependancy does not exist in node_modules, but inpackages/foo
directory. In this case, creating an alias(@jjangga0214/foo
->packages/foo/src
(ts: Path Mapping)) is needed.
(There is another case (e.g. "exporting" alias), but I'd like to omit them as not needed in this context.)
Node.js: Subpath Imports
There are several 3rd party solutions that resolves modules aliases.
- Runtime mapper:
module-alias
, etc. - Symlink:
link-module-alias
, etc. - Transpiler/bundler: Babel plugins, Rollup, Webpack, etc.
- Post-compile-processor:
tsc-alias
, etc.
However, from node v14.6.0 and v12.19.0, node introduced a new native support for it, named Subpath Imports.
It enables specifying alias path in package.json.
It requires prefixing an alias by #
.
This repo uses Subpath Import.
foo
's package.json:
{
"name": "@jjangga0214/foo",
"imports": {
"#foo/*": {
"default": "./dist/*.js"
}
}
}
There is engines
restriction in package.json, as subpath imports
is added from nodejs v14.6.0 and v12.19.0.
// package.json
{
"engines": {
"node": ">=14.6.0"
}
}
If you nodejs version does not fit in, you can consider 3rd party options.
Typescript: Path Mapping
Though we can avoid a runtime error(Module Not Found
) for module alias resolution, compiling typescript is stil a differenct matter.
Fot tsc, tsconfig.json
has this path mappings configuration.
{
"baseUrl": "packages",
"paths": {
"@jjangga0214/*": ["*/src"], // e.g. `@jjangga0214/foo` -> `foo/src`
"#foo/*": ["foo/src/*"], // e.g. `#foo/hello` -> `foo/src/hello.ts`
"#bar/*": ["bar/src/*"],
"#*": ["*/src"] // e.g. `#foo` -> `foo/src`
}
}
@jjangga0214/foo
and @jjangga0214/bar
are only used for cross-package references. For example, bar
imports @jjangga0214/foo
in its src/index.ts
.
However, #foo
and #bar
are only used for package's interal use. For example, foo/src/index.ts
imports #foo/hello
, which is same as ./hello
.
Note that bar
must NOT import #foo
or #foo/hello
, causing errors. I'm pretty sure there's no reason to do that as prefixing #
is only for package's internal use, not for exporting in this scenario.
But importing @jjangga0214/foo/hello
in bar
makes sense in some cases. For that, you should explicitly add additaional configuration like this.
{
"baseUrl": "packages",
"paths": {
"@jjangga0214/*": ["*/src"],
+ "@jjangga0214/foo/*": ["foo/src/*"], // => this one!
// Other paths are ommitted for brevity
}
}
Be careful of paths
orders for precedence. If the order changes, like the below, #foo/hello
will be resolved to foo/hello/src
, not foo/src/hello
.
{
"baseUrl": "packages",
"paths": {
"#*": ["*/src"],
"#foo/*": ["foo/src/*"], // => this will not work!
"#bar/*": ["bar/src/*"] // => this will not work!
}
}
ts-node-dev
is used for yarn dev
.
You can replace it to ts-node
if you don't need features of node-dev
.
For ts-node-dev
(or ts-node
) to understand Path Mapping, tsconfig-paths
is used.
tsconfig.json:
{
"ts-node": {
"require": ["tsconfig-paths/register"]
// Other options are ommitted for brevity
}
}
Until wclr/ts-node-dev#286 is resolved, "ts-node"
field in tsconfig.json will be ignored by ts-node-dev
. Thus it should be given by command options (e.g -r
below.). This is not needed if you only use ts-node
, not ts-node-dev
.
Each packages' package.json:
{
"scripts": {
"dev": "ts-node-dev -r tsconfig-paths/register src/index.ts"
}
// Other options are ommitted for brevity.
}
In development environment, fast execution by rapid compilation is useful.
ts-node
is configured to use swc
internally.
(Refer to the official docs -> That's why @swc/core
and @swc/helpers
are installed.)
tsconfig.json:
{
"ts-node": {
"transpileOnly": true,
"transpiler": "ts-node/transpilers/swc-experimental"
// Other options are ommitted for brevity
}
}
-
VSCode only respects
tsconfig.json
(which can be multiple files) as of writing (until microsoft/vscode#12463 is resolved.). Other IDEs may have similar policy/restriction. In monorepo, as build-specific configurations (e.g.include
,exclude
,rootDir
, etc) are package-specific, they should be seperated from thetsconfig.json
. Otherwise, VSCode would not help you by its feature, liketype checking
, orgo to definition
, etc. For instance, it'd be inconvenient to work onfoo
's API inbar
's code. To resolve this, build-specific configuration options are located intsconfig.build.json
. (But keep in note that compilation would always work flawlessly even if you only havetsconfig.json
and let build-related options in there. The only problem in that case would be IDE support.) -
yarn build
in each package executestsc -b tsconfig.build.json
, nottsc -p tsconfig.build.json
. This is to use typescript's Project References feature. For example,yarn build
underbar
builds itself and its dependancy,foo
(More specifically,foo
is compiled beforebar
is compiled). Look atpackages/bar/tsconfig.build.json
. It explicitly refers../foo/tsconfig.build.json
. Thus,tsc -b tsconfig.build.json
underbar
will usepackages/foo/tsconfig.build.json
to buildfoo
. And this fits well with--incremental
option specified intsconfig.json
, as build cache can be reused iffoo
(or evenbar
) was already compiled before. -
Each packages has their own
tsconfig.json
. That's becausets-node-dev --project ../../tsconfig.json -r tsconfig-paths/register src/index.ts
would not find Paths Mapping, although../../tsconfig.json
is given tots-node-dev
(env varTS_NODE_PROJECT
wouldn't work, either). -
Path Mapping should only be located in "project root
tsconfig.json
", even if certain some aliases are only for package's internal use. This is becausetsconfig-paths
does not fully respect Project References (dividab/tsconfig-paths#153). (If you do not usetsconfig-paths
, this is not an issue.)
If you write test code in javascript, you can do what you used to do without additional configuration. However, if you write test code in typescript, there are several ways to execute test in general.
You can consider tsc
, @babel/preset-typescript
, ts-jest
, @swc/jest
, and so on. And there're pros/cons.
-
tsc
and@babel/preset-typescript
requires explict 2 steps (compilation + execution), whilets-jest
and@swc/jest
does not (compilation is done under the hood). -
@babel/preset-typescript
and@swc/jest
do not type-check (do only transpilation), whiletsc
andts-jest
do. (Note that@swc/jest
plans to implement type-check. Issue and status: swc-project/swc#571) (Note: Bytsc
, you can turn off transpilation (to save time) but force type-check (--noEmit
) only when you want (e.g. git commit hook). By doing so, for instance, you can run test many times (e.g. test failure -> fix -> test failure -> fix -> test success) very fast without type-check (e.g.@swc/jest
) on your local machine, and finally type-check beforegit commit
, reducing total time cost. Since microsoft/TypeScript#39122, using--incremental
and--noEmit
simultaneously also became possible.) -
@swc/jest
is very fast, andtsc
"can be" fast.- For example,
ts-jest
took 5.756 s while@swc/jest
took 0.962 s for entire tests in this repo. - You can use incremental(
--incremental
) compilation if usingtsc
. - If it's possible to turn off
tsc
's type-check,tsc
can become "much" faster. This behaviour is not implemented yet (issue: microsoft/TypeScript#29651).
- For example,
In this article, I'd like to introduce ts-jest
and @swc/jest
.
In this repo, @swc/jest
is preconfigured (as it is very fast of course).
However, you can change it as you want.
By ts-jest/utils
, Jest respects Path Mapping automatically by reading tsconfig.json
and moduleNameMapper
(in jest.config.js
), which are, in this repo, already configured as follows. See how moduleNameMapper
is handeled in jest.config.js
and refer to docs for more details.
jest.config.js:
const { pathsToModuleNameMapper } = require('ts-jest/utils')
// Note that json import does not work if it contains comments, which tsc just ignores for tsconfig.
const { compilerOptions } = require('./tsconfig')
module.exports = {
moduleNameMapper: {
...pathsToModuleNameMapper(
compilerOptions.paths /* , { prefix: '<rootDir>/' }, */,
),
},
// Other options are ommited for brevity.
}
To use ts-jest
, follow the steps below.
jest.config.js:
{
// UNCOMMENT THE LINE BELOW TO ENABLE ts-jest
// preset: 'ts-jest',
// DELETE THE LINE BELOW TO DISABLE @swc/jest in favor of ts-jest
"transform": { "^.+\\.(t|j)sx?$": ["@swc/jest"] }
}
And
yarn remove -W @swc/jest
swc
is very fast ts/js transpiler written in Rust, and @swc/jest
uses it under the hood.
Jest respects Path Mapping by reading tsconfig.json
and moduleNameMapper
(in jest.config.js
), which are, in this repo, already configured.
Do not remove(yarn remove -W ts-jest
) ts-jest just because you use @swc/jest
.
Though @swc/jest
replaces ts-jest
completely, ts-jest/utils
is used in jest.config.js.
jest.config.js:
const { pathsToModuleNameMapper } = require('ts-jest/utils')
// Note that json import does not work if it contains comments, which tsc just ignores for tsconfig.
const { compilerOptions } = require('./tsconfig')
module.exports = {
moduleNameMapper: {
...pathsToModuleNameMapper(
compilerOptions.paths /* , { prefix: '<rootDir>/' }, */,
),
},
// Other options are ommited for brevity.
}
If you want to configure moduleNameMapper
manually, then you don't need ts-jest
.
Currently swc does not provide some features of babel plugins (REF). Thus additional dependencies might be needed. (You will be able to know what to install by reading error message if it appears.)
A list of already installed packages in this repo is:
regenerator-runtime
: Needed when passing async callback to jest'sit
ortest
function.
eslint
and prettier
is used along each other. You can run yarn lint <target>
.
This repo accepted airbnb style. eslint-config-airbnb-base
and eslint-config-airbnb-typescript
is configured.
If you need jsx
and tsx
rules, you should install eslint-config-airbnb
instead of eslint-config-airbnb-base
.
yarn add -D -W eslint-config-airbnb
And reconfigure .eslintrc.js as follows.
.eslintrc.js:
// Other options are ommitted for brevity
const common = {
extends: [
'airbnb-base', // "-base" does not include tsx rules.
// 'airbnb' // Uncomment this line and remove the above line if tsx rules are needed. (Also install eslint-config-airbnb pacakge)
],
}
// Other options are ommitted for brevity
module.exports = {
overrides: [
{
files: [
'**/*.ts',
// '**/*.tsx' // Uncomment this line if tsx rules are needed.
],
extends: [
'airbnb-typescript/base', // "/base" does not include tsx rules.
// 'airbnb-typescript' // Uncomment this line and remove the above line if tsx rules are needed.
],
},
],
}
It is not for markdown itself, but for javascript code block snippet appeared in markdown.
It is needed to lint jest code.
By configuring overrides
in .eslintrc.js
, both of typescript and javascript files are able to be linted by eslint
. (e.g. So typescript rules are not applied to .js
files. Otherwise, it would cause errors.)
markdownlint-cli
uses markdownlint
under the hood, and the cli respects .markdownlintignore
. You can yarn lint:md <target>
.
You can also install vscode extension vscode-markdownlint
.
It is used as commit message linter. This repo follows Conventional Commits style for git commit message. @commitlint/config-conventional
is configured as preset, and commitlint
is executed by husky
for git's commit-msg
hook.
Husky
executes lint-staged
and commitlint
by git hooks. lint-staged
makes sure staged files are to be formatted before committed. Refer to .husky/*
for details.
Introducing some of commands specified in package.json
. Refer to package.json
for the full list.
# remove compiled js folders, typescript build info, jest cache, *.log, and test coverage
yarn clean
# measure a single test coverage of entire packages
yarn coverage
# open web browser to show test coverage report.
# run this AFTER running `yarn coverage`,
# to make it sure there are reports before showing them.
yarn coverage:show
# lint code (ts, js, and js snippets on markdown)
# e.g. yarn lint .
yarn lint <path>
# lint markdown
yarn lint:md <path>
# test entire packages
yarn test
# build entire packages in parallel
yarn build