10·02tsconfig

Effect carries its architecture in three type parameters: the success channel `A`, the expected-error channel `E`, and the requirements channel `R`. A loose `tsconfig` widens each of these silently, a missing service slips out of `R`, an `undefined` sneaks int

52 - TypeScript Config: The Flags the Effect Team Enables

Previous · Next

Effect carries its architecture in three type parameters: the success channel A, the expected-error channel E, and the requirements channel R. A loose tsconfig widens each of these silently, a missing service slips out of R, an undefined sneaks into an optional, a thrown defect goes untracked.

This step pins the strict flags the Effect core team itself enables in tsconfig.base.json, plus the two overlays that adapt them to a bundled app or a published Node library.

What you’ll work through

index.test.ts contains four beats. The first asserts the strict base config.

The next two cover the two overlays — bundled app vs published Node library. The fourth is the diagnostic counterpart: a weak config that lets Effect’s type guarantees silently slip.

By the end you should be able to look at any tsconfig.json and tell which flags are non-negotiable for Effect code.


The Strict Base

Configures the compiler to match repos/effect/tsconfig.base.json, the same file the Effect monorepo uses for every package it ships.

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": false,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"incremental": true,
"plugins": [{ "name": "@effect/language-service" }]
}
}

Details

  • strict: true turns on strictNullChecks, noImplicitAny, and the rest of the family. Without it, missing services silently become unknown in R.
  • exactOptionalPropertyTypes: true distinguishes { x?: string } (key may be absent) from { x?: string | undefined } (key may be undefined). Effect schemas and config records rely on the difference.
  • moduleDetection: "force" treats every file as a module. Combined with isolatedModules, it matches how bundlers and the Node ESM loader see the project.
  • incremental: true reuses build state. Effect packages are large; cold type-checks without it are slow enough to discourage running them.

When to Use

Every Effect project. Treat the base as non-negotiable; vary only the module / target overlay below.


Bundled-App Overlay

Wires the strict base into a project consumed by Vite, Next.js, or Bun, where the bundler, not tsc, emits output.

{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"noEmit": true,
"jsx": "preserve"
}
}

Details

moduleResolution: "bundler" matches what Vite and esbuild actually do, it understands exports maps in package.json (which Effect uses extensively) and does not require explicit .js extensions in source. noEmit: true keeps tsc as a pure type-checker; the bundler handles transpilation.

When to Use

Any application whose build step is owned by a bundler.


Node-Library Overlay

Wires the strict base into a package that ships to npm, including a CLI built with @effect/cli.

{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
}
}

Details

declarationMap: true lets downstream consumers jump from your published types to your source in their editors. composite: true enables project references, the mechanism the Effect monorepo uses to keep cross-package builds fast.

When to Use

Anything published to a registry or consumed by another TypeScript workspace.


Flag-by-Flag: Why Each One Matters

Flag What it catches Why Effect cares
strict implicit any, null bypasses Prevents R from quietly widening to unknown
exactOptionalPropertyTypes undefined in ?: fields Aligns with Schema.optional and Config records
noUnusedLocals dead bindings Catches orphaned const x = yield* … after edits
isolatedModules type-only imports compiled as values Required for Vite, esbuild, and swc to tree-shake
moduleDetection: "force" ambient scripts pretending to be modules Effect modules always export; treat ambient as a bug
target: "ES2022" downlevelled class fields, missing using using and class auto-accessors are used in services
skipLibCheck: true unrelated errors inside node_modules/*.d.ts Effect’s .d.ts graph is large; checking it is wasted


Encode the flags as a validator

The strict-base checklist is a policy. Encoding it as a function — one entry per required flag, each returning null (ok) or a named gap (needs fix) — turns “did the team set this up right?” into a single pass over the snapshot.

Filter the null results and you have the list of gaps.

import { Effect } from "effect"
type Check = (cfg: TsConfig) => { flag: string; missing: unknown } | null
const checks: ReadonlyArray<Check> = [
(c) => (c.strict === true ? null : { flag: "strict", missing: true }),
(c) =>
c.exactOptionalPropertyTypes === true
? null
: { flag: "exactOptionalPropertyTypes", missing: true },
(c) =>
c.moduleDetection === "force"
? null
: { flag: "moduleDetection", missing: "force" },
// ...one entry per required flag
]
const audit = (cfg: TsConfig) =>
Effect.sync(() => {
const gaps = checks.map((check) => check(cfg)).filter((x): x is NonNullable<typeof x> => x !== null)
return { ready: gaps.length === 0, gaps }
})

The shape — checks array + filter-null — generalises beyond tsconfig. The same pattern shows up in step 51 (project setup audit) and step 53 (four-layer validator).

Once you see it once you’ll reach for it again.

Your turn write the `audit` function as a `checks.map(...).filter(...)` pass. Verify it reports zero gaps for a strict-base tsconfig and the correct gap list for a weak one.


A Note on verbatimModuleSyntax

verbatimModuleSyntax forces type-only imports to use import type. It is useful, but the Effect core repo does not currently enable it.

If you turn it on, ensure your editor’s “organize imports” understands the distinction, otherwise every save will churn the import list.


Common Pitfalls

1, moduleResolution: "node" in 2025. The classic resolver ignores exports maps. Effect publishes its subpath exports (effect/Effect, @effect/platform/HttpClient) through exports.

Use "bundler" for apps or "NodeNext" for libraries.

2, Lowering target below ES2022. The using keyword and several class field features Effect relies on are silently downlevelled or rejected.

3, Disabling skipLibCheck. It surfaces errors in dependency .d.ts files you cannot fix and tanks build time. The Effect repo keeps it on, so should you.

4, One tsconfig.json for everything. Sharing the strict base via extends is how the Effect monorepo composes its app, library, and test configs. Inlining the flags everywhere produces drift.


`composite` / `incremental` flags activate. - [ ] Read the weak-config beat — it shows what silently breaks when strict flags are off.

See Also

  • Previous, 51-project-setup: makes sure the editor actually uses this config.
  • Next, 53-project-structure: file layout that this config validates.
  • Reference, repos/effect/tsconfig.base.json: the source of every flag above.

Run the tests: pnpm test 52-tsconfig


The full test suite

The index.test.ts for this lesson, verbatim from steps/52-tsconfig/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
// =============================================================================
// 28 - TypeScript Config: The Flags the Effect Team Enables
// =============================================================================
//
// The strict base mirrors repos/effect/tsconfig.base.json.
// Two overlays adapt it to bundled apps and to publishable Node libraries.
const strictBase = {
strict: true,
exactOptionalPropertyTypes: true,
noUnusedLocals: true,
noFallthroughCasesInSwitch: true,
forceConsistentCasingInFileNames: true,
isolatedModules: true,
moduleDetection: "force",
skipLibCheck: true,
incremental: true,
} as const
const bundledAppTsconfig = {
compilerOptions: {
...strictBase,
target: "ES2022",
module: "preserve",
moduleResolution: "bundler",
noEmit: true,
},
} as const
const nodeLibraryTsconfig = {
compilerOptions: {
...strictBase,
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
declaration: true,
declarationMap: true,
composite: true,
},
} as const
const findWeakSettings = (config: Record<string, unknown>) =>
[
config.strict === true ? null : "strict should be enabled",
config.exactOptionalPropertyTypes === true
? null
: "exactOptionalPropertyTypes should be enabled",
typeof config.module === "string" ? null : "module should be chosen explicitly",
].filter((value): value is string => value !== null)
describe("28 - TypeScript Config", () => {
// @beat
// id: bundled-app
// shape: multi-todo
// title: Bundled-app overlay
// teaches: An app consumed by Vite or Next layers moduleResolution=bundler and noEmit on top of the strict base.
// hint: the overlay keeps strict=true and adds moduleResolution: "bundler", noEmit: true
it("keeps the strict baseline for bundled apps", () => {
// Predict each value the bundled-app overlay is expected to hold.
expect(bundledAppTsconfig.compilerOptions.strict).toBe(/* @stub */ true /* @end */)
expect(bundledAppTsconfig.compilerOptions.moduleResolution).toBe(/* @stub */ "bundler" /* @end */)
expect(bundledAppTsconfig.compilerOptions.noEmit).toBe(/* @stub */ true /* @end */)
})
// @end-beat
// @beat
// id: node-library
// shape: multi-todo
// title: Node-library overlay
// teaches: A library shipped to npm layers NodeNext modules, declarations, and composite for project references.
// hint: module uses the NodeNext resolver string; declaration, declarationMap, and composite are all turned on
it("uses NodeNext + declarations for publishable libraries", () => {
// Predict the four overlay values that make a library publishable.
expect(nodeLibraryTsconfig.compilerOptions.module).toBe(/* @stub */ "NodeNext" /* @end */)
expect(nodeLibraryTsconfig.compilerOptions.declaration).toBe(/* @stub */ true /* @end */)
expect(nodeLibraryTsconfig.compilerOptions.declarationMap).toBe(/* @stub */ true /* @end */)
expect(nodeLibraryTsconfig.compilerOptions.composite).toBe(/* @stub */ true /* @end */)
})
// @end-beat
// @beat
// id: weak-config
// shape: one-todo
// title: Detect weak configs
// teaches: A validator turns the strict-flag checklist into a concrete diff against any tsconfig snapshot.
// hint: module is provided so it's fine; the two missing flags appear in declaration order
it("flags weak configs concretely", () => {
// Predict the gap report when only `module` is set.
expect(findWeakSettings({ module: "preserve" })).toEqual(/* @stub */ [
"strict should be enabled",
"exactOptionalPropertyTypes should be enabled",
] /* @end */)
})
// @end-beat
// @beat
// id: write-validator
// shape: one-todo
// title: Write the weak-config validator
// teaches: Encode the required flags as a filter-null pattern — each check either returns null (ok) or a named gap (needs fix).
// hint: check config.strict === true, config.exactOptionalPropertyTypes === true, typeof config.module === "string"; filter nulls
it("your turn: implement findWeakSettings", () => {
const myFindWeakSettings = (config: Record<string, unknown>): string[] =>
/* @stub */ [
config.strict === true ? null : "strict should be enabled",
config.exactOptionalPropertyTypes === true
? null
: "exactOptionalPropertyTypes should be enabled",
typeof config.module === "string" ? null : "module should be chosen explicitly",
].filter((value): value is string => value !== null) /* @end */
// all flags present — no gaps
expect(
myFindWeakSettings({ strict: true, exactOptionalPropertyTypes: true, module: "bundler" }),
).toEqual([])
// missing strict and module — two gaps
expect(myFindWeakSettings({ exactOptionalPropertyTypes: true })).toEqual([
"strict should be enabled",
"module should be chosen explicitly",
])
})
// @end-beat
})
← PreviousProject SetupNext →Project Structure