Topic 15.6
TypeScript in Monorepos
In one line
A monorepo holds several packages in one repository. TypeScript needs to know which package depends on which, build them in order, and read each package's .d.ts files, which is what workspaces, project references and package.json exports provide.
Think of it like this
Real-life example: A shopping mall. Many shops share one building, one car park and one security desk. Each shop has its own door and its own sign. A customer only sees the sign and the door (the public part) and never the stock room. A package in a monorepo is a shop: it shares the building (the repository) but shows other packages only what it chooses to publish.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Monorepo
- One Git repository that holds many packages (apps and libraries).
- Workspace
- An npm feature: packages inside the repo are linked together on npm install.
- Project reference
- A tsconfig entry saying this project depends on another, so tsc builds them in order.
- composite
- A tsconfig option that makes a project usable as a reference (it must emit declarations and a build info file).
- tsc -b
- Build mode: builds referenced projects in order and skips the ones that haven't changed.
- .tsbuildinfo
- A file tsc writes to remember what it already built, so the next build is faster.
- exports
- A package.json field listing which files a package exposes, and under which conditions.
- Condition
- A key in exports such as types, import or default that picks a file depending on who is asking.
- Public API
- Everything a package lets others use. For TypeScript it is the exported types in its .d.ts files.
- isolatedDeclarations
- A flag that makes you write explicit types on exports so .d.ts files can be made fast, one file at a time.
Step by step
01The layout
Two packages under packages/, a root package.json that lists them as workspaces, and a root tsconfig.json that only lists references. shared has no dependencies; app depends on shared.
{
"name": "acme",
"private": true,
"workspaces": ["packages/*"],
"devDependencies": { "typescript": "7.0.2" }
}
// acme/
// package.json
// tsconfig.base.json
// tsconfig.json (files: [], references to both packages)
// packages/shared/{package.json, tsconfig.json, src/index.ts}
// packages/app/{package.json, tsconfig.json, src/main.ts}02Settings shared by every package
Put common options in tsconfig.base.json and let each package extends it (Topic 12.1). composite and declaration are what allow other projects to reference the package.
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"strict": true,
"declaration": true,
"composite": true,
"skipLibCheck": true,
"types": []
}
}03The shared package and its exports
The package says: for the root import ., give TypeScript the .d.ts first and everyone else the .js file.
{
"name": "@acme/shared",
"version": "1.0.0",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
}04The app references it
references is what lets tsc -b know shared goes first. app imports @acme/shared by its package name, and the workspace link makes that resolve.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
"include": ["src"],
"references": [{ "path": "../shared" }]
}05Check one package without building the other: it fails
Running tsc on app alone fails if shared has not been built: there is no dist/index.d.ts to read yet. This is the single most common monorepo error, and the reason tsc -b exists.
$ npx tsc -p packages/app --noEmit
packages/app/src/main.ts(1,34): error TS2307: Cannot find module '@acme/shared' or its corresponding type declarations.06Build in order with tsc -b
--verbose explains each decision. The first time both projects are out of date, so shared is built, then app. The second time both are up to date and skipped. (The real lines start with a clock time, which is removed here. app mentions a buildinfo file because the failed --noEmit run above had already written one.)
$ npx tsc -b --verbose
Projects in this build:
* packages/shared/tsconfig.json
* packages/app/tsconfig.json
* tsconfig.json
Project 'packages/shared/tsconfig.json' is out of date because output file 'packages/shared/tsconfig.tsbuildinfo' does not exist
Building project 'packages/shared/tsconfig.json'...
Project 'packages/app/tsconfig.json' is out of date because buildinfo file 'packages/app/tsconfig.tsbuildinfo' indicates that program needs to report errors.
Building project 'packages/app/tsconfig.json'...
$ npx tsc -b --verbose # second run
Project 'packages/shared/tsconfig.json' is up to date because newest input 'packages/shared/src/index.ts' is older than output 'packages/shared/tsconfig.tsbuildinfo'
Project 'packages/app/tsconfig.json' is up to date because newest input 'packages/app/src/main.ts' is older than output 'packages/app/tsconfig.tsbuildinfo'07What the consumer sees
The generated declaration file is the whole public face of the package. Notice: no function body and no private variable.
export interface User {
id: string;
name: string;
}
export declare function greet(user: User): string;08A breaking change is caught in the other package
Rename User.name to fullName in shared and run npx tsc -b again. shared rebuilds, then app fails because its object still uses name. The exit code is 2, so CI stops.
$ npx tsc -b
packages/app/src/main.ts(3,28): error TS2353: Object literal may only specify known properties, and 'name' does not exist in type 'User'.
$ echo $?
2Try it yourself
- 1
Find the missing annotation
In the first example, add
: Optionsto thedefaultsline only. Predict how many errors remain. Two:createClientstill returns an inferred object (TS9016), andclienthas no annotation either (TS9010), because its type would have to be worked out from the call. Fix both and compare with the second example. - 2
Leak and un-leak
In the third example, delete the
: Clientreturn type. Two things happen. The parameterurlloses the type it got fromClientand becomes an implicitany(TS7006), so write(url: string). Then read the// ^?answer forc: it is the whole object literal's type,{ get: (url: string) => string; }. Add a second field such asextra: 1,to the returned object and the answer gainsextra: number;, andObject.keys(c)lists it too: it silently became public. - 3
Reorder the conditions
In the fourth example, add a condition
require: "./dist/index.cjs"beforetypesingood. Does TypeScript's answer change when its conditions are["types", "import"]? (No:requireisn't in its list, so it is skipped.) Now movedefaultfirst and see what happens.
Code & diagrams
This example sets declaration: true and isolatedDeclarations: true. A tool that writes the .d.ts from one file alone can't infer these types without running the checker, so tsc asks for explicit ones. The code still runs. The next example fixes it.
Console
(nothing was printed)Expected console output
GET /users (retries: 3)Clean. Each export now states its type, so the declaration file can be produced by copying the annotations. Delete ': Client' from createClient and read the error again.
Console
(nothing was printed)Expected console output
GET /users (retries: 3)Consumers see Client, a one-method interface. The secret stays in the closure (the function remembers the variables around it, and nothing else can reach them) and is not part of the type. If createClient returned an inferred object type instead, every field of that object would become public API, and removing one later would be a breaking change.
Console
(nothing was printed)Expected console output
GET /users (retries: 3, 9 chars of secret)
false [ 'get' ]A simplified model of the real algorithm (the real one also handles nested conditions and patterns). TypeScript asks for the conditions types and import. With 'import' listed before 'types' it matches the JavaScript file first and never reaches the declarations. A subpath that isn't listed (./internal) is not importable at all: exports is also a privacy wall.
Console
(nothing was printed)Expected console output
TypeScript, good order: ./dist/index.d.ts
TypeScript, bad order: ./dist/index.js
Node, good order: ./dist/index.js
Missing subpath: undefinedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to annotate an export
In the second example, delete : Client after createClient(options: Options).
Break #2
Build the app before the library
In a fresh clone of the scratch monorepo, run npx tsc -p packages/app --noEmit before shared has been built.
Break #3
Reorder the conditions
In the fourth example, look at the bad object (import before types).
Myth vs fact
Myth
Project references are only for huge repositories.
Fact
Any repository with two packages that depend on each other benefits: ordered builds, tsc -b skipping what is up to date, and a clear boundary between packages.
Myth
Workspaces make TypeScript find my packages.
Fact
Workspaces only link folders into node_modules. TypeScript still needs built .d.ts files (or exported source) and references/exports set up.
Myth
Everything I export is private unless I say so.
Fact
The opposite: everything you export is public API, including every field of an inferred return type. Return named interfaces.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
composite: trueforcesdeclaration: true, requires every file to be matched byincludeorfiles, and makesrootDirdefault to the tsconfig folder. TypeScript 6.0 changed therootDirdefault to the tsconfig folder for everyone. - ▸
Two competing styles: built packages (each package builds
dist/with.d.ts; strong boundaries, needstsc -b) and source packages (consumers read the.tsdirectly throughpathsorexports; no build step, but the consumer's compiler checks the library's source with the consumer's settings). Many monorepos use source for internal packages and built output for anything published. - ▸
In TypeScript 7.0
tsc -bcan build independent projects in parallel (the announcement calls--builders, the number of parallel project builders, and--checkers, the number of type-checking workers, experimental;--singleThreadedruns the work on one thread), which is where monorepos feel the speed-up most. - ▸
Publishing both ESM and CommonJS needs matching declaration files (
.d.tsand.d.cts), or consumers on the other module system see wrong types. Test a published package by installing it in a scratch project with each module system before you trust the setup; this course did not run such a check.
Remember this
- 1
The problem this solves: once a project grows past one app, you split code into packages (a shared library, a web app, a server). Now one package imports another, and TypeScript has to answer new questions: which package must be built first, what exactly does the other package offer, and how do you stop a package from leaking its inside details?
- 2
Recall before you start. Topic 11.3: a
.d.tsfile holds only types, no code. Topic 11.2:moduleResolutiondecides how an import finds a file, andexportsinpackage.jsontakes part. Topic 12.4:compositeandreferenceswithtsc -bbuild projects in order. Topic 12.1:extendsshares settings between tsconfig files. - 3
A monorepo is one Git repository with many packages: for example
packages/shared(types and helpers),packages/app(the web app) andpackages/server. npm workspaces ("workspaces": ["packages/*"]in the rootpackage.json) makenpm installlink the packages intonode_modules/@acme/..., soimport { greet } from "@acme/shared"finds the folder next door. In the scratch run,node_modules/@acme/sharedwas a link topackages/shared. - 4
TypeScript needs two more things. First, it must know the order:
appimportsshared, sosharedhas to be built first. That is what project references do (Topic 12.4):app'stsconfig.jsonlists"references": [{ "path": "../shared" }], andsharedsetscomposite: true(anddeclaration: true).tsc -b(build mode) then builds the projects in dependency order and skips the ones that are up to date, using thetsconfig.tsbuildinfofile it leaves behind. - 5
Second, it must read what
sharedpromises, not its source. A package's public API is its.d.tsfiles (Topic 11.3):tscwritesdist/index.d.tscontaining only the types of what you exported. In the scratch run it was five lines (interface User,declare function greet). This is exactly what a consumer sees: no function bodies, no private variables. The live example below shows what that means for what you leak. - 6
**
package.jsonexportssays which files a package exposes and under which conditions** (types,import,default, and so on). A tool walks the conditions in order and takes the first match, so **typesmust come first**. The third live example is a small resolver that shows the same package resolved to a.d.tsfile withtypesfirst and to a.jsfile withtypessecond. WithmoduleResolutionnodenextorbundler(Topic 11.2) TypeScript readsexports; oldernode10resolution ignores it. - 7
Internal packages. Not every package needs a build. A package used only inside the monorepo can export its
.tssource directly ("exports": "./src/index.ts") if the app's bundler compiles it. This is quick but ties it to tools that understand TypeScript, and the consumer'stscthen checks the other package's source with its own settings. Packages meant to be published should be built and ship.d.ts. - 8
Publishing a typed library needs: compiled
.js, generated.d.ts(withdeclaration: true, or fast withisolatedDeclarations), anexportsmap withtypesfirst, and afileslist so only the build is in the tarball.npm pack --dry-runshows the tarball (the single archive file that npm uploads) before you publish. In the scratch run it listed six files includingsrc/index.ts,tsconfig.jsonandtsconfig.tsbuildinfobecause there was nofilesfield. Always check it. - 9
isolatedDeclarations(TypeScript 5.5) asks you to write the type of every exported thing explicitly, so a tool can produce the.d.tsby looking at one file at a time, without running the type checker. That is much faster in a large monorepo and lets bundlers emit declarations. The cost is a few more annotations, as the first two live examples show. Do not leak types by accident either: an exported function whose return type is inferred exposes whatever it returns (the fourth example returns aClientinterface so the secret stays inside).
Explain it without notes
What do npm workspaces do and what do they not do for TypeScript?
What are project references and what does tsc -b do with them?
Why must types come first in an exports map?
What does isolatedDeclarations require, and why does it help in monorepos?
What does a consumer of your library actually see, and how can you leak internals by accident?
Practice
Write the exports field for a package with a root entry (.) and one subpath ./testing, each with types and default, where the files live in dist/.
A function export function makeStore() { return { items: [] as string[], add(s: string) { this.items.push(s); } }; } leaks its shape. Write an interface that exposes only add and count(), and fix the function.
Trade-offs
- ↔
Built packages with references give strong boundaries and fast incremental builds, at the price of a build step and
distfolders to manage. Source packages are simpler but let one package's settings leak into another. - ↔
More packages mean clearer ownership and more configuration. Split when a boundary is real (shared by several apps, or published), not before.
Done when you can
I can set up npm workspaces with a shared package and an app.
I can use
compositeandreferencesand explain whattsc -bdoes.I can write an
exportsmap withtypesfirst.I can say what a consumer sees of my package, and avoid leaking internals.
I know what
isolatedDeclarationsasks for and why.I can check a tarball with
npm pack --dry-runbefore publishing.