Skip to content

TypeScript configuration

A monorepo can grow a tsconfig per package, a base file, references and path aliases, and each one is a place for settings to drift. This page answers how many programs there are, which flags are on, and which compiler binary actually runs the Effect diagnostics.

Impact: HIGH one file cannot drift from itself

  • The root tsconfig.json is the only tsconfig. Every .ts file under apps/, packages/ and scripts/, plus root files like vitest.config.ts and alchemy.run.ts, is in one program under one set of flags.
  • No references and no composite: they exist to emit, and we emit nothing. Packages export source.
  • No paths. A workspace import resolves through pnpm’s symlink and the package’s exports to the source file, as Node does at runtime. A paths alias would resolve a package the importer never declared (see monorepo tooling).
  • Accepted cost: one types and one lib for every file, and no per-package incremental check.

❌ Incorrect — a base file, per-package configs, and an alias that bypasses the graph:

packages/domain/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "composite": true, "paths": { "@app/contracts": ["../contracts/src"] } },
"references": [{ "path": "../contracts" }]
}

✅ Correct — one program at the root:

// tsconfig.json (root) — the only tsconfig in the repo
{
"$schema": "./node_modules/@effect/tsgo/schema.json",
"include": ["*.ts", "apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts"],
"compilerOptions": { /* … */ }
}

Source: notes/11-repo-operations/typescript-config.md · Decision 1

Impact: HIGH optionalKey is only a distinction with exactOptionalPropertyTypes

  • exactOptionalPropertyTypes makes “absent, never undefined” real (see schema modeling). noUncheckedIndexedAccess types xs[i] as T | undefined, which is what it is at runtime.
  • noImplicitOverride because services and tagged errors are classes; noFallthroughCasesInSwitch because a fall-through is almost always a missing return.
  • noErrorTruncation because Effect types are long and a truncated error hides the E/R mismatch.
  • Not set: noUnusedLocals/noUnusedParameters (oxlint already fails check on them), isolatedModules, noPropertyAccessFromIndexSignature.

❌ Incorrect — two gates for unused variables, and Effect errors cut off:

"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true

✅ Correct — the strictness block:

"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noErrorTruncation": true,
"skipLibCheck": true

Source: notes/11-repo-operations/typescript-config.md · Decision 2

Resolve like Node: nodenext, .ts specifiers, erasable syntax

Section titled “Resolve like Node: nodenext, .ts specifiers, erasable syntax”

Impact: HIGH tsc rejects what Node would reject at startup

  • module: nodenext models what runs the code: Node runs our .ts directly by stripping types. It also makes a missing import extension a compiler error (TS2835), which bundler accepts.
  • allowImportingTsExtensions + noEmit. verbatimModuleSyntax + erasableSyntaxOnly: type-only imports say import type, and there are no enums, namespaces or parameter properties.
  • Every package.json declares "type": "module". tsc does not check it, so a manifest that drops it is a review comment.
  • A dependency that fails to resolve under nodenext is fixed at that dependency (a types condition or a local declare module), never by switching to bundler.

❌ Incorrect — bundler resolution and syntax Node cannot strip:

// "moduleResolution": "bundler"
import { Rule } from "./rule" // no extension, accepted
export enum Status { Active, Paused } // not erasable

✅ Correct — what Node runs is what tsc checks:

// "module": "nodenext"
import type { Rule } from "./rule.ts"
export const Status = Schema.Literals(["active", "paused"])

Source: notes/11-repo-operations/typescript-config.md · Decision 3

Run tsc --noEmit from typescript@7, patched by effect-tsgo

Section titled “Run tsc --noEmit from typescript@7, patched by effect-tsgo”

Impact: HIGH the wrong binary drops every Effect diagnostic with exit 0

  • The compiler is the typescript package at 7.x, binary tsc, patched by effect-tsgo patch in prepare. There is no @typescript/native-preview and no tsgo binary.
  • tsgo --noEmit runs the wrong binary: it either does not exist, or it is an unpatched preview that silently skips the language-service diagnostics set to error (see linting and formatting).
  • tsc --noEmit runs once from the root, inside check. typescript and @effect/tsgo are catalog entries bumped together in one commit, to a version that @effect/tsgo lists as supported.
  • Check the gate is armed: the version prints the patch, e.g. 7.0.2+effect-tsgo.0.47.2.

❌ Incorrect — the unpatched preview binary:

"devDependencies": { "@typescript/native-preview": "…" },
"scripts": { "check": "… && tsgo --noEmit" }

✅ Correct — the patched tsc:

"scripts": {
"prepare": "effect-tsgo patch",
"check": "node scripts/workspace-graph.ts && node scripts/circular.ts && oxfmt --check && oxlint && tsc --noEmit"
},
"devDependencies": { "typescript": "catalog:", "@effect/tsgo": "catalog:" }

Source: notes/11-repo-operations/typescript-config.md · Decision 4

Write types for the runtime profile; never add DOM

Section titled “Write types for the runtime profile; never add DOM”

Impact: MEDIUM without a provider, AbortSignal and URL become a silent any

  • types is always written: TypeScript 7 loads no @types/* on its own. It is the one line that differs by profile (see runtime and entrypoint).
  • lib is ["esnext"] everywhere. The web globals Effect’s declarations name come from @types/node, bun-types or workers-types, never the browser lib.
  • The @types/node major matches .node-version. On the Worker profile workers-types are ambient, since bindings come from alchemy.
  • Accepted cost: on the Worker profile every file sees Worker globals, including Node scripts.
profile "types"
Node (default) ["node"]
Bun ["bun"]
Cloudflare Worker ["node", "@cloudflare/workers-types"]

❌ Incorrect — no types, and the browser lib for web globals:

"lib": ["esnext", "DOM"]

✅ Correct — the profile’s types, no DOM:

"lib": ["esnext"],
"types": ["node"]

Source: notes/11-repo-operations/typescript-config.md · Decision 5

  • Incremental checking, first "incremental": true with a tsBuildInfoFile under node_modules/.cache/, project references only after that — trigger: the root tsc --noEmit takes more than 60 seconds locally.
  • A second program (another tsconfig.json with its own types/lib and a second tsc -p in check) — trigger: the first workspace member that runs outside the project’s runtime profile, such as a browser app that needs DOM and JSX.