Loading...

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

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(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<T>() 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 ? T & CastBrand & Exact : T, TypeScript must prove value satisfies Exact 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 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(...) call.
  • Configured it to auto-fix data as PaymentIntentcast(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() 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 vs Promise scores 0.0, User vs Omit 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 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 undefinedbut 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 QueryObserverResultincluding 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 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 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 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(...). 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, 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.