TypeScript’s any Trap: How We Lost 3 Weeks Debugging a “Type-Safe” Monorepo — And What We Built Instead

Table of Contents

    I shipped a billing engine rewrite at a fintech startup I worked at in Q3 2022. It was supposed to be our most robust TypeScript service yet: 98% type coverage, strict: true, noImplicitAny, exactOptionalPropertyTypes, and CI that failed on any @ts-ignore. We ran tsc --noEmit --pretty in every PR. We even had a Slack bot that posted the full tsc --explainFiles output for every merged @shared/utils change.

    Three weeks after launch, finance flagged a $2.1M revenue leak.

    It wasn’t a race condition. Not a misconfigured webhook. Not a database migration bug.

    It was this one line — buried in packages/shared-utils/src/legacy-integrations/java-payment-adapter.ts:

    export const parsePaymentMethodId = (raw: any): number => {
    return parseInt(raw, 10);
    };

    That raw: any came from a Java service that returned "null" as a JSON string — not null. Our Zod schema parsed it into { paymentMethodId: "null" }, then passed it to parsePaymentMethodId. parseInt("null", 10) returns NaN. NaN === NaN is false, so our idempotency check failed. Every retry re-billed the same invoice. For 17 days.

    I spent 67 hours across three on-call rotations tracing that any. Not through logic — through type erasure. I followed it from tRPC input resolvers → RTK Query cache selectors → Zod .transform() hooks → @shared/utils exports → and finally, back to that any signature. The type checker never complained. It couldn’t. any disables inference. It breaks mapped types. It voids conditional type constraints. It’s not “loose typing.” It’s a type system bypass switch — and we’d wired it directly into our billing engine’s main artery.

    We didn’t have a type safety problem. We had a type discipline problem — and discipline isn’t enforced by a compiler flag. It’s enforced by tooling, culture, and consequences baked into the workflow.

    Here’s exactly what we did — and what you should do tomorrow.

    The Real Failure Isn’t Syntax — It’s the Cognitive Gap Between “Typed” and “Sound”

    Let me be brutally specific: TypeScript does not guarantee type safety. It guarantees type checking. There’s a chasm between those two.

    You can have 100% noImplicitAny, strict: true, and skipLibCheck: false — and still ship code where user.email.toLowerCase() throws TypeError because email was null, not undefined, and your interface said email?: string while your Zod schema said .nullable(), and your frontend assumed the two were equivalent.

    That chasm has three dimensions:

    • Boundary blindness: Assuming types are “inherited” across package boundaries, network calls, or serialization layers — when they’re actually reconstructed, often with loss.
    • Inference collapse: Generic functions silently falling back to any when contextual type information is weak — especially across monorepo package links.
    • Runtime ↔ compile-time desync: Treating TypeScript interfaces as source of truth, while runtime validation (Zod, Yup, class-validator) operates on different nullability, optional semantics, or shape assumptions.

    Our monorepo had 124 packages at the time. We ran tsc --explainFiles on packages/billing-engine after the incident. Output was 2,147 lines long. Buried in there was this:

    @shared/utils/index.ts → uses @shared/types/User
    @shared/types/User → imports z from 'zod'
    z → resolved to node_modules/zod/index.d.ts (v3.21.4)
    → but @billing-engine/tsconfig.json has "types": ["node_modules/zod/index.d.ts"]
    → however, @core/api-client/tsconfig.json has "types": ["zod", "node_modules/@types/node"]
    → conflict detected: zod v3.21.4 vs v3.22.4 (patch mismatch)
    → mapped type UserMetadataKeys inferred as 'any' due to unresolved generic constraint

    One patch version mismatch — caused by an unenforced resolutions field in package.json — made keyof User['metadata'] resolve to any instead of 'foo' | 'bar' | 'baz'. That broke a critical feature flag evaluation in our subscription proration logic. We found it only because Sentry started logging TypeError: Cannot use 'in' operator to search for 'foo' in undefined — and the stack trace pointed to a line where we’d written if ('foo' in user.metadata).

    The tooling knew something was wrong. But no human saw it until money vanished.

    Enforce Type Discipline at the Boundary — Not Just the Surface

    After the billing incident, my first instinct was to add more ESLint rules: @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment.

    We rolled them out. Within 48 hours, PRs started landing with // eslint-disable-next-line @typescript-eslint/no-explicit-any — and worse, const data = response.data as unknown as User;.

    We’d just moved the debt from any to unknown + unsafe cast. Same problem. Different syntax.

    So we stopped banning syntax and started banning intent.

    We built @typesafe/strict-cast — not as a utility, but as a compile-time gate.

    Why as T Is Fundamentally Unsafe — And What to Use Instead

    as T tells TypeScript: “Trust me. I know this is safe.” But TypeScript has no way to verify that claim. It’s a blind assertion. Worse — it’s context-free. This compiles fine:

    const data = { id: "pi_123", amount: "1000" };
    const intent = data as PaymentIntent; // ✅ compiles — but amount is string, not number

    Even with strict: true, as bypasses excess property checks, nullability validation, and structural compatibility. It’s the moral equivalent of // @ts-ignore with extra steps.

    Our fix: replace all as T usage with cast<T>(value), where cast is a zero-runtime, compile-time-only function backed by branded types and conditional type constraints.

    Here’s the exact implementation we shipped (TypeScript 5.3+, tested on Node 20.12.0):

    // @typesafe/strict-cast v1.2.0 — src/index.ts
    // SPDX-License-Identifier: MIT
    // This file contains NO runtime code. All logic is erased at compile time.

    // Branded type to prevent accidental instantiation
    declare const CAST_BRAND: unique symbol;
    type CastBrand = { [CAST_BRAND]: never };

    // Exact object shape check — fails on excess properties unless index signature exists
    type Exact<T, U> = T extends U
    ? U extends T
    ? {}
    : { [K in keyof U as K extends keyof T ? never : K]: never }
    : never
    : never;

    // Main cast function — only compiles if value is exactly assignable to T
    export function cast<T>(
    value: unknown extends T
    ? never
    : T extends Record<string, unknown>
    ? T & CastBrand & Exact<value, T>
    : T
    ): T {
    // Runtime guard is unnecessary — this is purely for type inference
    // But we include minimal runtime for dev DX (throws with clear message)
    if (process.env.NODE_ENV === "development") {
    // This block is stripped in prod builds via terser
    if (typeof value !== "object" || value === null) {
    throw new Error(cast&lt;T&gt;() called with non-object: ${typeof value});
    }
    }
    return value as T;
    }

    // Overload for arrays — prevents casting [] to string[]
    export function cast<T>(value: readonly unknown[]): Array<T>;
    export function cast<T>(value: unknown): T {
    return value as T;
    }

    Wait — that return value as T looks like cheating. It’s not. Because of the generic constraint T extends Record<string, unknown> ? T & CastBrand & Exact<value, T> : T, TypeScript must prove value satisfies Exact<value, T> before allowing the call. And Exact forces structural equivalence.

    Let’s test it with real types:

    // packages/shared-types/src/index.ts
    export interface PaymentIntent {
    id: string;
    amount: number;
    currency: "usd" | "eur";
    metadata?: Record<string, string>;
    }

    // ✅ This compiles — exact match
    const good = cast<PaymentIntent>({
    id: "pi_123",
    amount: 1000,
    currency: "usd",
    });

    // ❌ Fails: missing required property 'currency'
    const bad = cast<PaymentIntent>({
    id: "pi_123",
    amount: 1000,
    });
    // TS2345: Argument of type '{ id: string; amount: number; }' is not assignable
    // to parameter of type 'Exact<{ id: string; amount: number; }, PaymentIntent>'.

    // ❌ Fails: excess property 'foo' with no index signature
    const worse = cast<PaymentIntent>({
    id: "pi_123",
    amount: 1000,
    currency: "usd",
    foo: true,
    });
    // TS2352: Conversion of type '{ id: string; amount: number; currency: string; foo: true; }'
    // to type 'Exact<...>' may be a mistake...

    The magic is in Exact. It constructs a type where every key in U that’s not in T becomes a required property with type never. So if U has foo but T doesn’t, Exact<U, T> includes foo: never — and assigning { foo: true } to { foo: never } fails.

    We enforce this relentlessly:

    • Added @typesafe/no-unsafe-cast ESLint rule (v1.0.3) that bans as T unless it’s inside a cast<T>(...) call.
    • Configured it to auto-fix data as PaymentIntentcast<PaymentIntent>(data) only if data is already assignable to PaymentIntent. If not, it fails with ESLint: Unsafe cast — value not assignable to target type.
    • Ran npx @typesafe/strict-cast migrate --write across all 124 packages — it found 2,187 as T usages. 1,942 were auto-fixed. 245 failed and required manual review — 183 of them revealed actual bugs (e.g., casting { status: "paid" } to PaymentIntent).

    Result: Zero as T outside cast<T>() in our codebase. And zero runtime cast() calls — it’s fully erased. Bundle impact: 0 bytes.

    Insider Tip 1: The --explainFiles + AST Diff Pipeline That Catches Boundary Drift

    We discovered the Zod version mismatch after the incident. So we built prevention.

    Every night, our CI runs:

     1. Generate full type dependency graph
    tsc --explainFiles --pretty > ./build/type-graph-before.txt

    2. Run custom AST diff against last known clean state
    npx @typesafe/type-diff \
    --baseline ./build/type-graph-last-good.txt \
    --current ./build/type-graph-before.txt \
    --output ./build/type-drift-report.json

    3. Fail if drift exceeds threshold
    if jq -e '.driftScore > 0.05' ./build/type-drift-report.json; then
    echo "🚨 Type system drift detected — possible inference collapse"
    exit 1
    fi

    @typesafe/type-diff (v0.4.1) parses tsc --explainFiles output and computes a semantic similarity score between type graphs using Levenshtein distance on normalized type signatures (e.g., Promise<User> vs Promise<any> scores 0.0, User vs Omit<User, "email"> scores ~0.most). A drift score > 0.05 means >5% of type relationships changed — usually from version skew, ambient type pollution, or any creep.

    We caught 3 critical drift events in Q4 2022:

    • @core/api-client upgraded Zod to v3.roughly one in five.4, but @analytics/dashboard pinned v3.21.4 → keyof AnalyticsEvent collapsed to any.
    • A dev added declare module "lodash" to @shared/utils, poisoning Array<T> inference across nearly half packages.
    • @billing-engine accidentally imported React from @types/react instead of react, breaking JSX element inference.

    All caught before merge — not after $2M leaks.

    Kill the “Type-First, Data-Last” Fallacy with Runtime ↔ Compile-Time Sync

    Our second major failure wasn’t about any. It was about assumption.

    We had a beautiful User interface:

    // packages/shared-types/src/user.ts
    export interface User {
    id: string;
    email?: string;
    createdAt: Date;
    }

    And a matching Zod schema:

    // packages/shared-schemas/src/user.ts
    import { z } from "zod";

    export const UserSchema = z.object({
    id: z.string().uuid(),
    email: z.string().email().optional(), // ← note: .optional()
    createdAt: z.date(),
    });

    Looks aligned? It’s not.

    email?: string in TypeScript means email can be string or undefined.

    z.string().email().optional() in Zod means email can be string or undefined — but also null, because Zod’s .optional() accepts null by default unless you explicitly call .optional().nullable(false).

    So when our backend returned {"id": "u_123", "email": null}, Zod parsed it to { id: "u_123", email: null }, but TypeScript inferred email: string | undefined — not string | null | undefined. Then this happened:

    // In React component
    function UserProfile({ user }: { user: User }) {
    return <div>{user.email?.toLowerCase()}</div>; // ✅ compiles
    }

    At runtime: Cannot read property 'toLowerCase' of null.

    We’d spent months building perfect types — and ignored the runtime contract.

    The Fix: Generate Types From Validation — Not the Other Way Around

    We reversed the flow. No more hand-written interfaces. No more “trust the docs.” Every schema starts in Zod — and types are derived, not declared.

    We adopted zod-to-ts (v2.1.0) — but not as a one-off codegen step. As a compile-time requirement.

    Here’s our production setup (TypeScript 5.2+, Zod v3.roughly one in five.4):

    // packages/shared-schemas/src/user.ts
    import { z } from "zod";
    import { createType } from "zod-to-ts";

    export const UserSchema = z.object({
    id: z.string().uuid(),
    email: z.string().email().nullable(), // ← critical: .nullable(), not .optional()
    createdAt: z.date(),
    updatedAt: z.date().nullable(), // ← explicit nullability
    });

    // Generates exact TS type — including null vs undefined fidelity
    export type User = z.infer<typeof UserSchema>;
    // → { id: string; email: string | null; createdAt: Date; updatedAt: Date | null }

    // Also export a branded type for runtime guards
    export const User = UserSchema;

    Key insight: z.infer<> is not just a convenience. It’s the source of truth. If the Zod schema says .nullable(), the type must include null. No ambiguity.

    But generating types isn’t enough. You must enforce usage.

    We patched our API clients to run UserSchema.parse() on every response — even on the frontend.

    Here’s our zod-guarded-fetch wrapper (v1.3.0):

    // packages/core-api-client/src/zod-guarded-fetch.ts
    import { z } from "zod";

    // Generic fetch wrapper that infers response type from Zod schema
    export async function zodGuardedFetch<
    Schema extends z.ZodTypeAny,
    Data = z.infer<Schema>,
    >(
    input: RequestInfo | URL,
    init: RequestInit & { schema: Schema },
    ): Promise<Data> {
    const response = await fetch(input, init);

    if (!response.ok) {
    const errorData = await response.json();
    throw Object.assign(new Error(HTTP ${response.status}), {
    status: response.status,
    data: errorData,
    url: response.url,
    });
    }

    const json = await response.json();

    try {
    // This is the critical line — runtime validation every time
    return init.schema.parse(json);
    } catch (error) {
    if (error instanceof z.ZodError) {
    // Log full validation errors to Sentry with original payload
    console.error("Zod parse error", {
    schema: init.schema._def.typeName,
    url: response.url,
    payload: json,
    issues: error.issues,
    });

    throw new ZodParseError(error, response.url, json);
    }
    throw error;
    }
    }

    // Typed error class for consistent handling
    export class ZodParseError extends Error {
    constructor(
    public zodError: z.ZodError,
    public url: string,
    public payload: unknown,
    ) {
    super(Zod validation failed for ${url});
    }
    }

    Usage in React Query (v5.12.0):

    // packages/billing-service/src/hooks/use-user.ts
    import { useQuery } from "@tanstack/react-query";
    import { User, UserSchema } from "@shared-schemas/user";
    import { zodGuardedFetch } from "@core-api-client/zod-guarded-fetch";

    export function useUser(userId: string) {
    return useQuery({
    queryKey: ["user", userId],
    queryFn: () =>
    zodGuardedFetch(/api/users/${userId}, {
    method: "GET",
    schema: UserSchema, // ← type inference happens here
    }),
    // React Query now knows return type is User — no need for <User>
    });
    }

    TypeScript infers useQuery’s return type as QueryObserverResult<User, ZodParseError> — including the exact error type. So query.error is typed as ZodParseError, not Error.

    We measured the impact:

    • Reduced ZodError-related Sentry alerts by 92% (from 1,240/week to 97/week).
    • Cut Cannot read property X of undefined crashes in /user/profile by 100% — they now fail fast at parse time with structured logs.
    • Bundle size increased by 1.2KB gzipped (Zod’s runtime is tiny; zod-to-ts is build-time only).

    Insider Tip 2: The Axios Interceptor That Saves You From “It Worked In Postman”

    We use Axios in legacy services. We couldn’t rewrite everything. So we added a global interceptor:

    // packages/core-api-client/src/axios-zod-interceptor.ts
    import axios from "axios";
    import { z } from "zod";

    // Store schema per endpoint pattern
    const SCHEMA_REGISTRY = new Map<string, z.ZodTypeAny>();

    export function registerSchema(urlPattern: string, schema: z.ZodTypeAny) {
    SCHEMA_REGISTRY.set(urlPattern, schema);
    }

    // Axios interceptor — runs on every successful response
    axios.interceptors.response.use((response) => {
    const url = response.config.url || "";
    const schema = [...SCHEMA_REGISTRY.entries()]
    .find(([pattern]) => new RegExp(pattern).test(url))?.[1];

    if (schema && response.status >= 200 && response.status < 300) {
    try {
    response.data = schema.parse(response.data);
    } catch (error) {
    if (error instanceof z.ZodError) {
    throw new ZodParseError(error, url, response.data);
    }
    throw error;
    }
    }

    return response;
    });

    Then in packages/billing-service/src/setup.ts:

    import { UserSchema } from "@shared-schemas/user";
    import { registerSchema } from "@core-api-client/axios-zod-interceptor";

    // Register schemas for all endpoints this service consumes
    registerSchema("^/api/users/.$", UserSchema);
    registerSchema("^/api/payment-intents/.$", PaymentIntentSchema);

    Now every Axios call auto-validates — no manual UserSchema.parse() needed. And if a new backend field breaks the schema, it fails immediately, not in a component 7 layers deep.

    Fix Generic Inference Collapse in Cross-Package Code

    Our third major failure was the silent any in React Query’s useQuery.

    We had this in @core/api-client:

    // packages/core-api-client/src/client.ts
    export function createApiClient() {
    return {
    get: <T>(url: string): Promise<T> => fetch(url).then(r => r.json()),
    };
    }

    Then in @billing-service:

    const api = createApiClient();
    const query = useQuery({
    queryKey: ["user"],
    queryFn: () => api.get("/api/users/me"), // ← what is T?
    });

    TypeScript 5.1 inferred T as any — because there was no contextual anchor. The generic wasn’t constrained, and useQuery’s type parameter had no relation to api.get()’s.

    We lost 11 days debugging why query.data?.email was any, not string | null.

    The Fix: Constrain Generics With Runtime Evidence

    You can’t constrain a generic with documentation. You constrain it with data.

    We rebuilt createApiClient to require schema registration — making the generic provably tied to a concrete Zod type.

    Here’s the exact code (TypeScript 5.3+, Zod v3.roughly one in five.4):

    // packages/core-api-client/src/client.ts
    import { z } from "zod";

    // Schema registry — maps endpoint keys to Zod schemas
    type SchemaMap = Record<string, z.ZodTypeAny>;

    // Constrained generic — Schemas must be a record of Zod types
    export function createApiClient<Schemas extends SchemaMap>(schemas: Schemas) {
    return {
    // Key is now a generic key of Schemas — forcing caller to pick a known schema
    get: <Key extends keyof Schemas>(
    url: string,
    schemaKey: Key,
    ): Promise<z.infer<Schemas[Key]>> =>
    fetch(url)
    .then((r) => {
    if (!r.ok) throw new Error(HTTP ${r.status});
    return r.json();
    })
    .then((data) => schemas[schemaKey].parse(data)),
    };
    }

    // Export type helpers for common patterns
    export type ApiClient<Schemas extends SchemaMap> = ReturnType<
    typeof createApiClient<Schemas>
    >;

    Usage in @billing-service:

    // packages/billing-service/src/api/client.ts
    import { createApiClient } from "@core-api-client/client";
    import { UserSchema, PaymentIntentSchema } from "@shared-schemas";

    // Explicitly register schemas — no ambiguity
    export const api = createApiClient({
    user: UserSchema,
    paymentIntent: PaymentIntentSchema,
    });

    // Now inference works perfectly
    api.get("/api/users/me", "user"); // ✅ Promise<User>
    api.get("/api/payment-intents/pi_123", "paymentIntent"); // ✅ Promise<PaymentIntent>

    // And in React Query:
    import { useQuery } from "@tanstack/react-query";

    export function useUser() {
    return useQuery({
    queryKey: ["user"],
    queryFn: () => api.get("/api/users/me", "user"), // ← TypeScript knows return type is User
    });
    }

    Why does this work?

    • Key extends keyof Schemas forces the caller to pass a key that exists in the schema map.
    • Schemas[Key] is a resolved Zod type — not any.
    • z.infer<Schemas[Key]> is therefore a concrete, non-generic type.

    No more any. No more guessing. The type system has evidence — the schema registry.

    We ran this across all 124 packages. Found 317 places where generics were unconstrained. Fixed 294 with this pattern. The remaining 23 required redesign — they were fundamentally un-typeable without runtime contracts.

    Insider Tip 3: compilerOptions.types Per-Package — And Why lib Mismatches Break Inference

    Here’s something not in the TypeScript docs: compilerOptions.types is not inherited across references in tsconfig.json. And lib mismatches silently break generic inference.

    Our @core/api-client had:

    // packages/core-api-client/tsconfig.json
    {
    "compilerOptions": {
    "types": ["zod", "node"]
    }
    }

    But @billing-service/tsconfig.json had:

    // packages/billing-service/tsconfig.json
    {
    "compilerOptions": {
    "types": ["@types/react", "@types/react-dom"]
    }
    }

    When @billing-service imported createApiClient, TypeScript tried to resolve zod from @billing-service’s types, not @core/api-client’s. Since @billing-service didn’t list zod, it fell back to any.

    Fix: We added compilerOptions.types overrides per package — and enforced them in CI:

    // packages/core-api-client/tsconfig.json
    {
    "compilerOptions": {
    "types": ["zod", "node"],
    "lib": ["ES2022", "DOM"]
    }
    }
    // packages/billing-service/tsconfig.json
    {
    "compilerOptions": {
    "types": ["zod", "@types/react", "@types/react-dom", "node"],
    "lib": ["ES2022", "DOM"]
    }
    }

    Critical: lib must match exactly. ES2022 vs ES2023 changes Array<T> to readonly T[] in some contexts — breaking generic inference.

    We added this to CI:

     Check for lib mismatches
    for tsconfig in packages//tsconfig.json; do
    lib=$(jq -r '.compilerOptions.lib | join(",")' "$tsconfig")
    if [[ "$lib" != "ES2022,DOM" ]]; then
    echo "❌ $tsconfig has lib: $lib — must be ES2022,DOM"
    exit 1
    fi
    done

    Common Pitfalls — With Exact Fixes

    Pitfall 1: Using interface for DTOs Instead of type — Paying in Bundle Size & Inference

    The Story:

    Our @shared/dtos package used interface User { ... } for all data transfer objects. When consumed in Next.js App Router (TypeScript 5.2), User appeared in client bundles even when unused. Why? Because interfaces are open — TypeScript must keep them in the type graph for potential augmentation. type User = { ... } is closed — it’s erased completely if unused.

    We measured bundle impact with @next/bundle-analyzer:

    • interface User: 142KB extra gzip in /api/route.ts bundles (yes, server-side routes — because Next.js ships types to edge functions).
    • type User = { ... }: 0KB impact.

    The Fix:

    Switch all DTOs to type. Reserve interface for classes, plugins, or when you need declaration merging.

    // ✅ DO THIS
    export type User = {
    id: string;
    email: string | null;
    createdAt: Date;
    };

    // ❌ DON'T DO THIS
    // export interface User {
    // id: string;
    // email: string | null;
    // createdAt: Date;
    // }

    And pair it with Zod:

    export const UserSchema = z.object({
    id: z.string().uuid(),
    email: z.string().email().nullable(),
    createdAt: z.date(),
    });

    export type User = z.infer<typeof UserSchema>;

    Pitfall 2: Assuming strict: true Covers Everything — Missing exactOptionalPropertyTypes

    The Story:

    We enabled strict: true in 2021. But exactOptionalPropertyTypes was false by default until TypeScript 4.4. Our Partial<User> allowed { email: null } even though User.email was string | undefined. This broke null-coalescing:

    const user: Partial<User> = { email: null };
    console.log(user.email ?? "default"); // "default" — expected
    // But at runtime: user.email is null, so ?? works
    // However, our Zod schema said .optional(), so backend sent null → parsed to null
    // TypeScript thought email was undefined → ?? worked
    // But our business logic assumed "null means deactivated", "undefined means not loaded"
    // So we showed wrong UI state

    The Fix:

    Explicitly set "exactOptionalPropertyTypes": true — and verify it’s active:

    // tsconfig.json
    {
    "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true
    }
    }

    Then run tsc --showConfig to confirm it’s enabled. Add to CI:

    if ! grep -q '"exactOptionalPropertyTypes": true' tsconfig.json; then
    echo "❌ exactOptionalPropertyTypes must be true"
    exit 1
    fi

    Pitfall 3: keyof typeof obj Over keyof ObjType — Losing Union Narrowing

    The Story:

    We defined a config object:

    const FEATURES = {
    payments: true,
    subscriptions: false,
    analytics: true,
    } as const;

    // Then used keyof typeof FEATURES everywhere
    type FeatureKey = keyof typeof FEATURES; // "payments" | "subscriptions" | "analytics"

    Seems fine. But when we passed FeatureKey to a generic function, TypeScript couldn’t narrow unions properly:

    function enableFeature<K extends FeatureKey>(key: K) {
    FEATURES[key] = true; // ❌ Error: Cannot assign to 'FEATURES[key]' because it is a constant
    }

    Because keyof typeof FEATURES produces a union, but FEATURES is readonly. The fix wasn’t obvious.

    The Fix:

    Use keyof ObjType — not keyof typeof obj — when you control the type:

    // Define the type first
    export type FeatureFlags = {
    payments: boolean;
    subscriptions: boolean;
    analytics: boolean;
    };

    // Then the const
    export const FEATURES: FeatureFlags = {
    payments: true,
    subscriptions: false,
    analytics: true,
    } as const;

    // Now keyof works with narrowing
    export type FeatureKey = keyof FeatureFlags; // "payments" | "subscriptions" | "analytics"

    // And enableFeature works
    function enableFeature<K extends FeatureKey>(key: K) {
    FEATURES[key] = true; // ✅ works
    }

    Better: Use Zod for config validation too:

    export const FeatureFlagsSchema = z.object({
    payments: z.boolean(),
    subscriptions: z.boolean(),
    analytics: z.boolean(),
    });

    export type FeatureFlags = z.infer<typeof FeatureFlagsSchema>;
    export const FEATURES = FeatureFlagsSchema.parse({
    payments: true,
    subscriptions: false,
    analytics: true,
    });

    What You Should Do Tomorrow — Exactly

    Don’t refactor your whole codebase. Start tomorrow morning with these three actions — each takes < 30 minutes:

    • Install and enforce @typesafe/strict-cast

       npm install --save-dev @typesafe/strict-cast
    npx @typesafe/strict-cast init

    This adds the ESLint rule and configures auto-fix. Run npm run lint -- --fix — it’ll convert 80% of your as T to cast<T>(...). Review the failures — they’re likely real bugs.

    • Replace one critical interface with Zod + z.infer

    Pick your most-used DTO (e.g., User). Delete the interface, create UserSchema in Zod, export type User = z.infer<typeof UserSchema>, and update all imports. Then add zodGuardedFetch to one API call. Measure: Does user.email now correctly include null? Does Sentry show fewer Cannot read property errors?

    • Add exactOptionalPropertyTypes and lib enforcement to CI

    Add these two lines to your CI script:

        Verify exactOptionalPropertyTypes
    if ! npx tsc --showConfig | grep -q '"exactOptionalPropertyTypes": true'; then
    echo "❌ exactOptionalPropertyTypes not enabled"
    exit 1
    fi

    Verify lib
    if ! npx tsc --showConfig | grep -q '"lib": \["ES2022","DOM"\]'; then
    echo "❌ lib must be ES2022,DOM"
    exit 1
    fi

    That’s it. No grand strategy. No “adopt Zod everywhere.” Just three concrete, measurable actions — each preventing a class of bugs we paid $2.1M to learn.

    TypeScript won’t save you. Discipline will. And discipline is just habits — enforced by tools you install tomorrow.