52 - TypeScript Config: The Flags the Effect Team Enables
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: trueturns onstrictNullChecks,noImplicitAny, and the rest of the family. Without it, missing services silently becomeunknowninR.exactOptionalPropertyTypes: truedistinguishes{ x?: string }(key may be absent) from{ x?: string | undefined }(key may beundefined). Effect schemas and config records rely on the difference.moduleDetection: "force"treats every file as a module. Combined withisolatedModules, it matches how bundlers and the Node ESM loader see the project.incremental: truereuses 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/.
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})