Skip to main content

Chapter 4: TypeScript Configuration

TypeScript configuration is one of the most impactful decisions in a project. Loose settings let bugs through. Overly strict settings create friction without proportional safety. This chapter explains every setting that matters for our stack and why.

The Base Configuration

// tsconfig.json — project root
{
"compilerOptions": {
// === Type Checking (Maximum Safety) ===
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,

// === Module Resolution ===
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,

// === Interop ===
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,

// === Path Aliases ===
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},

// === JSX ===
"jsx": "react-jsx",

// === Libraries ===
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}

Setting-by-Setting Explanation

strict: true

This is a meta-flag that enables:

  • strictNullChecksnull and undefined are distinct types
  • strictFunctionTypes — function parameter types are checked contravariantly
  • strictBindCallApplybind, call, and apply are type-checked
  • strictPropertyInitialization — class properties must be initialized
  • noImplicitAny — every value must have a known type
  • noImplicitThisthis must have a declared type
  • useUnknownInCatchVariablescatch(e) gives e: unknown instead of e: any
  • alwaysStrict — emits "use strict" in every file

Non-negotiable. If strict is not true, you are using TypeScript as a linter for JavaScript, not as a type system.

exactOptionalPropertyTypes: true

interface Config {
theme?: "light" | "dark";
}

// With exactOptionalPropertyTypes: false (default)
const config: Config = { theme: undefined }; // ✅ Allowed — but is this right?

// With exactOptionalPropertyTypes: true
const config: Config = { theme: undefined }; // ❌ Error!
const config: Config = {}; // ✅ Correct — property is absent

This setting distinguishes between "property is absent" ({}) and "property is explicitly undefined" ({ theme: undefined }). These are semantically different operations and should be typed differently.

Required for Effect. Effect's type system relies on this distinction for its Requirements channel. Without it, you will get subtle bugs where services appear provided but are actually undefined.

noUncheckedIndexedAccess: true

const items = ["a", "b", "c"];

// Without noUncheckedIndexedAccess
const first = items[0]; // Type: string — but what if the array is empty?

// With noUncheckedIndexedAccess
const first = items[0]; // Type: string | undefined — forces you to check
if (first !== undefined) {
console.log(first.toUpperCase()); // Now safe
}

Array indexing and object property access with dynamic keys return T | undefined instead of T. This catches a common class of runtime errors where code assumes an element exists at an index.

verbatimModuleSyntax: true

Requires using import type { X } for type-only imports instead of import { X }. This makes it explicit which imports are erased at runtime and which produce actual module loads.

// ✅ Correct with verbatimModuleSyntax
import type { User } from "./types";
import { formatUser } from "./utils";

// ❌ Error — User is a type but imported as a value
import { User } from "./types";

Benefits:

  • Clear distinction between runtime and type-level code
  • Prevents accidental side-effect imports
  • Better tree-shaking (bundlers know which imports can be removed)

moduleResolution: "bundler"

This is the correct resolution strategy for Vite projects. It:

  • Supports package.json exports field
  • Resolves .ts extensions when allowImportingTsExtensions is enabled
  • Understands node_modules resolution
  • Does not try to resolve .js files (Vite handles that)

Do not use "node" (legacy) or "node16" (for published packages). "bundler" is purpose-built for code that will be processed by a bundler like Vite.

isolatedModules: true

Ensures each file can be independently transpiled without cross-file type information. This is required by Vite (which uses esbuild or SWC for transpilation) because these tools process files individually, unlike tsc which has access to the full program.

Practical impact: disallows const enum, namespace merging, and re-exporting types without export type.

skipLibCheck: true

Skips type checking .d.ts declaration files from node_modules. This dramatically improves compilation speed (3-10x in large projects) at the cost of not catching errors in library type definitions.

In practice, library type errors are rare and should be reported upstream rather than caught locally. The speed improvement is worth it.

Path Aliases in Practice

The @/* alias maps to src/*. This means:

// Instead of this:
import { Button } from "../../../components/ui/button";
import { UserService } from "../../services/user-service";

// You write this:
import { Button } from "@/components/ui/button";
import { UserService } from "@/services/user-service";

Benefits:

  • Refactoring does not break imports when files move
  • Consistent import style regardless of file depth
  • Easier to read and understand import origins

Important: The path alias must also be configured in Vite (we did this in the previous chapter):

// vite.config.ts
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},

And in Vitest (handled automatically since Vitest reads vite.config.ts).

Monorepo TypeScript Configuration

In a monorepo, share TypeScript configuration through a dedicated package:

// packages/config/typescript/base.json
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
}
}
// packages/config/typescript/react.json
{
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"allowImportingTsExtensions": true
}
}
// packages/config/typescript/library.json
{
"extends": "./base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

Then in each app or package:

// apps/web/tsconfig.json
{
"extends": "@taskforge/config/typescript/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"references": [
{ "path": "../../packages/shared" },
{ "path": "../../packages/db" }
]
}

Vite-Specific Type Declarations

Vite injects types for import.meta.env and other Vite-specific APIs. Ensure this declaration file exists:

// src/vite-env.d.ts
/// <reference types="vite/client" />

// Extend import.meta.env with your app's variables
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
readonly VITE_API_BASE_URL: string;
readonly VITE_ENABLE_DEVTOOLS: string;
readonly VITE_LOG_LEVEL: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

This gives you autocomplete and type checking when accessing import.meta.env.VITE_APP_NAME.

Common TypeScript Patterns for This Stack

Branded Types

Use branded types (nominal typing) to prevent mixing up IDs:

import { Brand } from "effect";

type UserId = string & Brand.Brand<"UserId">;
type ProjectId = string & Brand.Brand<"ProjectId">;
type OrganizationId = string & Brand.Brand<"OrganizationId">;

// TypeScript prevents passing a UserId where a ProjectId is expected
function getProject(id: ProjectId): Promise<Project> { /* ... */ }

const userId = "user-123" as UserId;
getProject(userId); // ❌ Type error!

Discriminated Unions for State

type AsyncState<T, E = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };

// TypeScript narrows the type in each branch
function renderState<T>(state: AsyncState<T>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <DataView data={state.data} />; // state.data is available
case "error":
return <ErrorView error={state.error} />; // state.error is available
}
}

Exhaustive Checking

// Utility that errors at compile time if a switch is not exhaustive
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}

type Status = "active" | "archived" | "completed";

function getStatusLabel(status: Status): string {
switch (status) {
case "active":
return "Active";
case "archived":
return "Archived";
case "completed":
return "Completed";
default:
return assertNever(status); // Compile error if a case is missing
}
}

satisfies for Type Checking Without Widening

// Routes configuration with type checking but preserving literal types
const routes = {
home: "/",
projects: "/projects",
projectDetail: "/projects/:id",
settings: "/settings",
} satisfies Record<string, string>;

// Type is preserved as the literal object, not widened to Record<string, string>
routes.home; // Type: "/"
routes.nonexistent; // ❌ Error — property doesn't exist

TypeScript Anti-Patterns to Avoid

any Escape Hatch

// Never do this
const data: any = await fetchData();
const result = data.foo.bar.baz; // No type safety at all

// Instead, decode unknown data through a schema
const data: unknown = await fetchData();
const result = Schema.decodeUnknownSync(MySchema)(data);

❌ Non-Null Assertion (!)

// Dangerous — you're telling TypeScript to trust you
const element = document.getElementById("root")!;

// Safer — handle the null case
const element = document.getElementById("root");
if (!element) {
throw new Error("Root element not found");
}

❌ Type Assertions (as)

// Lying to the compiler
const user = response.data as User; // What if it's not actually a User?

// Validate at runtime instead
const user = Schema.decodeUnknownSync(UserSchema)(response.data);

❌ Overly Permissive Generics

// Too loose — T could be anything
function process<T>(data: T): T { return data; }

// Constrained — T must have the properties we need
function process<T extends { id: string; name: string }>(data: T): T {
return data;
}

Summary

Our TypeScript configuration prioritizes safety:

  • strict: true — the baseline, non-negotiable
  • exactOptionalPropertyTypes — required for Effect, good practice regardless
  • noUncheckedIndexedAccess — catches array/object access bugs
  • verbatimModuleSyntax — explicit type vs. value imports
  • moduleResolution: "bundler" — correct for Vite projects
  • ✅ Path aliases (@/*) — clean, refactoring-safe imports
  • ✅ Shared configs in monorepo — consistent settings everywhere

Every setting has a reason. None are ceremonial. The goal is to make the TypeScript compiler your most reliable team member — catching errors before they reach runtime.