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
anywhen 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, 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, 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-castESLint rule (v1.0.3) that bansas Tunless it’s inside acastcall.(...) - Configured it to auto-fix
data as PaymentIntent→castonly if(data) datais already assignable toPaymentIntent. If not, it fails withESLint: Unsafe cast — value not assignable to target type. - Ran
npx @typesafe/strict-cast migrate --writeacross all 124 packages — it found 2,187as Tusages. 1,942 were auto-fixed. 245 failed and required manual review — 183 of them revealed actual bugs (e.g., casting{ status: "paid" }toPaymentIntent).
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-clientupgraded Zod to v3.roughly one in five.4, but@analytics/dashboardpinned v3.21.4 →keyof AnalyticsEventcollapsed toany.- A dev added
declare module "lodash"to@shared/utils, poisoningArrayinference across nearly half packages. @billing-engineaccidentally importedReactfrom@types/reactinstead ofreact, 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 — 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 undefinedcrashes in/user/profileby 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-tsis 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 Schemasforces the caller to pass a key that exists in the schema map.Schemas[Key]is a resolved Zod type — notany.z.inferis 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.tsbundles (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
exactOptionalPropertyTypesandlibenforcement 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.