Best practices for TypeScript monorepo
blog.flycode.com
blog.flycode.com
Nice that they included a footnote about pnpm. Makes me want to write a counter article about using pnpm instead. pnpm is faster than yarn 1 (variable with yarn 2, depending on use) and workspaces are just easier. Lest we forget hoisting and dependency dedupe, which is far and away superior with pnpm.
My pnpm + TS setup is as follows:
- /shared/ directory at root that contains tsconfig.base.json, tsconfig.eslint.json, tsconfig.json (which is only symlinked to, has relative settings for the directories it's linked into)
- tsconfig.json at the root, extending shared/tsconfig.base.json, including all things that an editor might care about
- /packages/ (or /services/) at the root
- /packages/{package}/tsconfig.json -> symlinked to -> /shared/tsconfig.json
This setup allows various editors to validate code in any directory that might have TS (and JS!), editor plugins that use ESLint to have a TS config reference, and allows deployment and/or build processes to operate on individual entities in packages|services without having to build the world. Note that this isn't ideal in monorepos where you want to build the world. In monorepos where I have packages and services, for example, and the services are dependent on the packages build built, I leverage pnpm's recursive script ability with filtering to build some of the world, but not all.
(Happy to answer questions on pnpm monorepos)
[1] https://github.com/pnpm/meta-updater
[2] https://github.com/pnpm/pnpm/blob/main/.meta-updater/src/ind...
import { FormType } from '../../../../types/form';
import { DateType } from '../../../../types/date';
// This is considered bad practice
Disagree on this one. Beauty of typescript is giving you auto-import autocompletion in your editor, and not having to worry about relative paths any more. I don't think I've manually written an import statement in years.IMO: Extreme opinions one way or the other don't work, and thus (most of the time) code-complete automation also doesn't work.
import { User } from "./User"; # is probably the most readable and comprehensible way to write this.
import { User } from "app/domain/users/types/User"; # is probably less comprehensible
import { User } from "../User"; # may be ok, but it may also depend on how deep this directory is, because
import { User } from "app/users"; # is actually pretty comprehensible
The point should be to make it really quick for readers to understand where this code is coming from. Imagine a situation where you've got identically named imports; like a import { User } from "foo/db/types"; # used to schematize a user in the database
import { User } from "foo/api/createUser/types"; # used to schematize a component of the request body for creating users
Maybe the tokens themselves are named poorly, but look past that.Many people find a specific file using, simply, CMD+P (or your editor's equivalent). There's no context for where they may be at; which inherently makes relative imports less comprehensible.
import { User } from "./User";
const u = new User(req.body.user); # but wait... what kind of user did I just create?
There are many ways to help alleviate this low state of comprehension; and what should be deployed is difficult to create hard-and-fast rules around because its so domain specific. import { User } from "foo/api/createUser/types";
# having an import path like this can help, but maybe only in small files where the imports aren't 500 lines above where they're used.
import { User as CreateUserAPIUser } from "./User";
# aliasing imports is a good solution
import * as createUserApiTypes from "foo/api/createUser/types";
const u = new createUserApiTypes.User(req.body.user);
# importing, then properly naming, the entire module is in my experience an underutilized tool to help with comprehension.Do y’all look at the import path at the top of a file to understand a function on line 450?
Any editor I can think of provides a way to show what you’re using here. I don’t think import path intelligibility should really be that high on the list of concerns.
> (most of the time) code-complete automation also doesn't work
They’re pretty great these days, in my experience.
It depends on the language, the editor, and the development process. There's no one-size-fits-all solution.
Regardless of all of that: code files are near their peak productivity when they can fit on-screen without scrolling, and when you can put all that aside and just immediately move your eyes from the token to the import path. And they're even closer to the peak when you can combine that with encoded contextual information about the import path (which is generally a reflection of what the token does) into the token itself; via, say, import aliasing or importing the module and accessing the token as a property on that imported module.
The reality that we have thousand-line source files obviously isn't ideal. No one who has ever worked with one would say "this is the way the world should be". But, they exist, and thus we have tools like Intellisense to make them manageable, and they'd definitely be less comprehensible without those tools. That doesn't make the whole situation ideal, when talking in hypotheticals. We shouldn't admit defeat to the God of Complexity by saying, even in this small corner of good practices, "well, the comprehensibility of the import path doesn't matter because we gave up on making anything comprehensible a long time ago."
> They’re pretty great these days, in my experience.
I don't mean that they don't work in the sense that they don't produce compileable import paths. Though this is true in some languages, or some projects with bespoke configuration systems, or in some editors.
I mean that they don't work because they don't oftentimes produce comprehensible import paths.
Cut that number by 2/3 and the situation is the same, right? How often are you working on files where the entirety of them fit on screen at all times…
> well, the comprehensibility of the import path doesn't matter because we gave up on making anything comprehensible a long time ago
I think they don’t matter because even modest complexity produces this situation. This isn’t a hypothetical, it’s the far reaching majority of real, practical code.
Yarn Berry has a node_modules mode now, which makes it behave like Yarn Classic and NPM. It also allows you to import sibling projects in a monorepo:
"peerDependencies": {
"my-library": "workspace:*",
which means you can import { thing } from 'my-library'
and have it work as expected, even when my-library is a sibling project in your monorepo.I feel like it keeps things clean while also providing a way to distinguish between closely related modules and imports from further away in your application.
DO NOT HAVE CYCLIC DEPENDENCIES BETWEEN PROJECTS
This will prevent all sort of caching systems (including tsc's own --incremental flag) to work.
These are my package.json scripts for detecting cycles:
"cycles": "run-p cycles:client cycles:server",
"cycles:client": "dpdm client/src/\*/\*.ts --exit-code circular:1",
"cycles:server": "dpdm server/src/server.ts --exit-code circular:1",
The exit code is set like that so that the CI will fail on cycles, since we run this in our CI build.I also hear people use ESLint, probably this: https://github.com/import-js/eslint-plugin-import/blob/main/...
The fact that the compiler itself doesn't error on cycle imports, and that the errors caused by those imports are so opaque, seems like an oversight to me.
https://github.com/sverweij/dependency-cruiser
NX[0] also has logic for handling this issue
[0]: https://nx.dev/
Very solid repository. No complaints. Pleasant to work in
Just a few years back, the monorepo-tooling-landscape left much to be desired, there were a lot of opinionated 'zero-config' tools out there that always seemed to fall apart the moment you strayed from their happy path. I even went so far to create my own tool (https://github.com/abuob/yanice), in parts because it was fun and taught me a lot and in parts because I simply didn't find something fitting our usecase.
It's cool that the tooling in this area is getting better and better, monorepos solve a lot of very annoying enterprise problems but require solid tooling to make it work, even when way smaller than google-scale.
I've always felt this one should be built into the TypeScript compiler. Most of my projects are set up to share some utilities and its annoying to integrate babel or other tools just to fix the paths. I've previously used [ttypescript](https://github.com/cevek/ttypescript) with the [typescript-transform-paths](https://www.npmjs.com/package/typescript-transform-paths) plugin. Gosh, it would be enough if TypeScript just natively supported plugins in the tsconfig (which is what ttypescript provides).
I want to be able to quickly validate that a change that I made didn't introduce any errors. The problem is that our repo is a few hundred thousand lines, and doing `yarn run tsc` takes a long time. I currently use VSCode, which does good at incremental feedback in the current file, but there's still a blind spot where I'm not sure if I've introduced issues that affect files that I'm not currently editing.
"typescript.tsserver.experimental.enableProjectDiagnostics": true
This has been a game-changer for me. Any files in the project that are broken immediately show up as red and then I can go find what's wrong. It's a refactoring dream. I use an M1 Mac and it does just fine with big projects.
That is effectively what happens in VSCode, via the lsp, so I'm guessing something is being lazy and its a bug with the integration.
Which is a long winded thing to say that it's still often useful in VS Code to keep a full tsc --watch process around even if you think the LSP's horizon is wide enough to catch everything because tsc --watch will always have different performance priorities to VSCode's editing experience.
If you are not using Vercel caching, I’ve built my own open-source turbo cache backend[1] that can be self-hosted
My monorepo looks like this:
project-1/packages/core
project-1/packages/preact
project-2/packages/core
project-2/packages/demos
etc.
where a bunch of related projects live top-level in a repo. Each project has a packages folder that includes the core implementation, as well as demos, framework-specific adaptors, etc.In each package's package.json, I have a series of commands (convert the TS to JS, make a bundle, deploy to Firebase, etc.). Each command can depend on another, either in the same project or anywhere else in the file hierarchy.
This provides two benefits:
1. Iterating across packages is faster, because I don't have to worry about making sure each package rebuilds in the right order if I make a change in a library.
2. Filesystem concerns are separated: rollup only needs to worry about bundling, and it only needs to bundle web-facing projects. The only tool my libraries need is tsc.
(Before wireit, using TypeScript and Rollup together was a pain in the ass because you'd have to fiddle with picking the right TS plugin and configuring it. This was also often the long pole on doing a Rollup version upgrade. Decoupling the two makes Rollup way simpler/easier/nicer to use, which makes wireit awesome even if you don't have multiple packages.)
It's also a good replacement for lerna. Each of my packages has a publish command that runs `gitpkg publish`. My root package.json has a publish command that depends on all the packages' publish commands. Thus, I can run `yarn run publish` in the root of my monorepo and trust that all of my packages have been published to our git host appropriately. (gitpkg lets you turn a git host into a private registry, so you can share modules without setting up an NPM registry.)
Here's a snippet from one of my package.jsons. They basically all look like this. (start is complicated because of https://github.com/google/wireit/issues/33. When that's resolved, it will be as simple as the others.)
"scripts": {
"start": "yarn run -TB concurrently \"yarn run build:tsc --watch\" \"rollup --config ./rollup.config.js --watch\" \"dhost site --bind-all\"",
"build": "yarn run -TB wireit",
"build:tsc": "yarn run -TB wireit",
"build:bundle": "yarn run -TB wireit",
"deploy": "yarn run -TB wireit"
},
"wireit": {
"build": {
"dependencies": [
"build:tsc",
"build:bundle"
]
},
"build:tsc": {
"command": "tsc --build --pretty",
"clean": "if-file-deleted",
"files": [
"src/**/*.{ts,tsx}",
"tsconfig.json",
"../../tsconfig.common.json"
],
"output": [
"dist/**",
".tsbuildinfo"
],
"packageLocks": [
"yarn.lock"
],
"dependencies": [
"../core:build",
"../preact:build"
]
},
"build:bundle": {
"command": "rollup --config ./rollup.config.js",
"clean": true,
"files": [
"dist/**/*.js",
"rollup.config.js"
],
"output": [
"site/mount.js",
".tsbuildinfo"
],
"packageLocks": [
"yarn.lock"
],
"dependencies": [
"build:tsc"
]
},
"deploy": {
"command": "firebase deploy",
"dependencies": [
"build:bundle"
]
}
},