ADR-0003: TypeScript Strict Mode Configuration
Last Updated: 2026-01-08 Status: Active Context: Decksmith
Context
TypeScript is already chosen as the primary language for Decksmith. However, how we configure TypeScript determines how effective it is at catching bugs.
The key questions:
- Should we enable strict mode?
- What compiler options should be standard across all packages?
- How do we structure
tsconfig.jsonfiles in a monorepo? - How strict should we be about type checking dependencies?
This decision affects the entire codebase and is difficult to change later (tightening strictness requires fixing many errors).
Current Decision
We will use TypeScript strict mode with the following configuration strategy:
- Base
tsconfig.jsonat repository root with strict mode enabled - All packages extend the base config with environment-specific overrides (DOM for web, Node for backend)
- Enable all strict flags including
noUncheckedIndexedAccess - Use
moduleResolution: "bundler"for modern tooling (Vite, esbuild, Rollup) - Set
skipLibCheck: truefor flexibility with third-party dependencies - Enable project references for incremental builds and better IDE performance
Key compiler options:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"skipLibCheck": true,
"verbatimModuleSyntax": true
}
}Rationale
Why Strict Mode
"Clarity over cleverness" — TypeScript strict mode makes implicit behavior explicit:
strict: true: Enables all strict type-checking options:noImplicitAny: Forces explicit typing (noanyby default)strictNullChecks:nullandundefinedare not assignable to other typesstrictFunctionTypes: Functions are contravariant in parametersstrictBindCallApply: Type-check.bind(),.call(),.apply()strictPropertyInitialization: Class properties must be initializednoImplicitThis:thismust have explicit typealwaysStrict: Emit"use strict"in JavaScript output
noUncheckedIndexedAccess: true: Array/object access returnsT | undefinedtypescriptconst arr = [1, 2, 3]; const x = arr[10]; // Type: number | undefined (not just number)This prevents the #1 source of runtime errors in TypeScript: assuming array indices are valid.
noImplicitOverride: true: Requiresoverridekeyword when overriding base class methods- Prevents accidental overrides when base class changes
noFallthroughCasesInSwitch: true: Catches missingbreakstatements in switch cases
Why moduleResolution: "bundler"
Modern bundlers (Vite, esbuild, Rollup) support:
- ESM and CJS imports interchangeably
- Extensionless imports (
import { foo } from './foo'resolves tofoo.ts) - Package
exportsfield resolution
"bundler" mode matches this behavior, while "node16" requires explicit extensions (.js), which is verbose for TypeScript projects.
Why skipLibCheck: true
Pragmatic flexibility: Many third-party libraries have minor type errors or depend on old TypeScript versions. Checking library types can cause:
- Build failures from dependencies we don't control
- Slower type-checking
- Maintenance burden (waiting for library updates)
Trade-off: We won't catch type errors in dependencies, but we gain:
- Faster builds
- Fewer blockers from third-party type issues
- More flexibility to use the npm ecosystem
Our code is still strictly checked — skipLibCheck only affects node_modules, not our source code.
Why verbatimModuleSyntax: true
Forces explicit import type and export type for type-only imports:
import type { User } from './types'; // Type-only import
import { getUser } from './api'; // Value importBenefits:
- Better tree-shaking (bundlers can remove type-only imports)
- Clearer intent (explicit about types vs values)
- Prevents runtime imports of types
Why Base Config + Extensions
Monorepo structure:
/tsconfig.json (base)
/apps/web/tsconfig.json (extends base, adds DOM)
/apps/api/tsconfig.json (extends base, adds Node types)
/packages/domain/tsconfig.json (extends base, minimal)Benefits:
- Consistency: All packages have same strict settings
- DRY: No duplication of compiler options
- Easy updates: Change base config once, all packages inherit
- Environment-specific: Each package can add DOM or Node types as needed
Trade-offs
Benefits:
- Fewer runtime errors: Strict mode catches entire classes of bugs at compile time
- Better autocomplete: Explicit types improve IDE suggestions
- Easier refactoring: TypeScript can track changes across the codebase
- Consistent standards: All packages use same strict settings
- Self-documenting code: Explicit types serve as inline documentation
- Faster builds:
skipLibCheck: truereduces type-checking time
Costs:
- More verbose code: Must explicitly type parameters, return values, nullability
- Longer compile times: Strict checks take more time (mitigated by incremental builds and skipLibCheck)
- Steeper learning curve: New contributors must understand strict TypeScript
- No dependency type checking: Won't catch type errors in third-party libraries
Risks:
- False sense of security: TypeScript guarantees are only at compile time. Runtime validation (Zod) is still needed.
- Mitigation: ADR-future will define validation strategy (Zod at boundaries)
- Overly strict for prototyping: Strict mode can slow down experimentation
- Mitigation: For quick prototypes, use
@ts-expect-errorwith a comment explaining why
- Mitigation: For quick prototypes, use
- Breaking changes when tightening: If we start with loose config, tightening later requires fixing many errors
- Mitigation: Start strict from day 1 (this ADR)
Evolution History
2026-01-08: Initial decision
- Enabled all strict mode flags
- Chose
moduleResolution: "bundler"for modern tooling - Set
skipLibCheck: truefor pragmatic flexibility with dependencies - Established base config + extensions pattern
References
- TypeScript Strict Mode Documentation
- TypeScript 5.0+ Module Resolution
- Why noUncheckedIndexedAccess matters
- Related ADR: ADR-0002 (Monorepo structure explains base config inheritance)