Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~25 min· topic 6 of 6

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.

package.json (root) and layoutwhole filejson
{
  "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.

tsconfig.base.jsonwhole filejson
{
  "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.

packages/shared/package.jsonwhole filejson
{
  "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.

packages/app/tsconfig.jsonwhole filejson
{
  "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.

terminal (TypeScript 7.0.2)whole filebash
$ 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.)

terminalwhole filebash
$ 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.

packages/shared/dist/index.d.ts (generated)whole filets
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.

terminalwhole filebash
$ 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 $?
2
A breaking change is caught in the other packagediagram
Rendering diagram…

Try it yourself

  1. 1

    Find the missing annotation

    In the first example, add : Options to the defaults line only. Predict how many errors remain. Two: createClient still returns an inferred object (TS9016), and client has 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. 2

    Leak and un-leak

    In the third example, delete the : Client return type. Two things happen. The parameter url loses the type it got from Client and becomes an implicit any (TS7006), so write (url: string). Then read the // ^? answer for c: it is the whole object literal's type, { get: (url: string) => string; }. Add a second field such as extra: 1, to the returned object and the answer gains extra: number;, and Object.keys(c) lists it too: it silently became public.

  3. 3

    Reorder the conditions

    In the fourth example, add a condition require: "./dist/index.cjs" before types in good. Does TypeScript's answer change when its conditions are ["types", "import"]? (No: require isn't in its list, so it is skipped.) Now move default first and see what happens.

Code & diagrams

isolatedDeclarations in a shared package: annotate every export TypeScript 5.5+

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)
The fix: explicit types on everything exported TypeScript 5.5+

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)
A package's public type is what you promise, not what you wrote

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' ]
exports conditions: the first match wins, so `types` goes first

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:        undefined

Break 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).

terminal
$ Types panel (with isolatedDeclarations on)
── what you'll see ──
index.ts(13,5): error TS9016: Objects that contain shorthand properties can't be inferred with --isolatedDeclarations.

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.

terminal
$ npx tsc -p packages/app --noEmit
── what you'll see ──
packages/app/src/main.ts(1,34): error TS2307: Cannot find module '@acme/shared' or its corresponding type declarations.

Break #3

Reorder the conditions

In the fourth example, look at the bad object (import before types).

terminal
$ Console
── what you'll see ──
TypeScript, bad order: ./dist/index.js

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: true forces declaration: true, requires every file to be matched by include or files, and makes rootDir default to the tsconfig folder. TypeScript 6.0 changed the rootDir default to the tsconfig folder for everyone.

  • ▸

    Two competing styles: built packages (each package builds dist/ with .d.ts; strong boundaries, needs tsc -b) and source packages (consumers read the .ts directly through paths or exports; 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 -b can build independent projects in parallel (the announcement calls --builders, the number of parallel project builders, and --checkers, the number of type-checking workers, experimental; --singleThreaded runs the work on one thread), which is where monorepos feel the speed-up most.

  • ▸

    Publishing both ESM and CommonJS needs matching declaration files (.d.ts and .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. 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. 2

    Recall before you start. Topic 11.3: a .d.ts file holds only types, no code. Topic 11.2: moduleResolution decides how an import finds a file, and exports in package.json takes part. Topic 12.4: composite and references with tsc -b build projects in order. Topic 12.1: extends shares settings between tsconfig files.

  3. 3

    A monorepo is one Git repository with many packages: for example packages/shared (types and helpers), packages/app (the web app) and packages/server. npm workspaces ("workspaces": ["packages/*"] in the root package.json) make npm install link the packages into node_modules/@acme/..., so import { greet } from "@acme/shared" finds the folder next door. In the scratch run, node_modules/@acme/shared was a link to packages/shared.

  4. 4

    TypeScript needs two more things. First, it must know the order: app imports shared, so shared has to be built first. That is what project references do (Topic 12.4): app's tsconfig.json lists "references": [{ "path": "../shared" }], and shared sets composite: true (and declaration: true). tsc -b (build mode) then builds the projects in dependency order and skips the ones that are up to date, using the tsconfig.tsbuildinfo file it leaves behind.

  5. 5

    Second, it must read what shared promises, not its source. A package's public API is its .d.ts files (Topic 11.3): tsc writes dist/index.d.ts containing 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. 6

    **package.json exports says 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 **types must come first**. The third live example is a small resolver that shows the same package resolved to a .d.ts file with types first and to a .js file with types second. With moduleResolution nodenext or bundler (Topic 11.2) TypeScript reads exports; older node10 resolution ignores it.

  7. 7

    Internal packages. Not every package needs a build. A package used only inside the monorepo can export its .ts source 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's tsc then checks the other package's source with its own settings. Packages meant to be published should be built and ship .d.ts.

  8. 8

    Publishing a typed library needs: compiled .js, generated .d.ts (with declaration: true, or fast with isolatedDeclarations), an exports map with types first, and a files list so only the build is in the tarball. npm pack --dry-run shows the tarball (the single archive file that npm uploads) before you publish. In the scratch run it listed six files including src/index.ts, tsconfig.json and tsconfig.tsbuildinfo because there was no files field. Always check it.

  9. 9

    isolatedDeclarations (TypeScript 5.5) asks you to write the type of every exported thing explicitly, so a tool can produce the .d.ts by 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 a Client interface so the secret stays inside).

Explain it without notes

01

What do npm workspaces do and what do they not do for TypeScript?

02

What are project references and what does tsc -b do with them?

03

Why must types come first in an exports map?

04

What does isolatedDeclarations require, and why does it help in monorepos?

05

What does a consumer of your library actually see, and how can you leak internals by accident?

Practice

01

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/.

02

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 dist folders 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 composite and references and explain what tsc -b does.

  • I can write an exports map with types first.

  • I can say what a consumer sees of my package, and avoid leaking internals.

  • I know what isolatedDeclarations asks for and why.

  • I can check a tarball with npm pack --dry-run before publishing.