Loading...

TypeScript Isn’t a Type Checker — It’s a Contract Negotiator (And Most Teams Are Signing Blank Checks)

You just spent three hours debugging why user.profile?.settings?.theme threw “Cannot read property ‘theme’ of undefined” in production — even though your IDE said it was safe, and the type says Profile | null. You didn’t miss a null check. Your type lied.

It wasn’t a bug in the runtime. It wasn’t a race condition. It wasn’t an async footgun. It was a contract you thought you’d signed — but hadn’t actually negotiated.

You typed profile: Profile | null, and assumed that meant “if it’s not null, it’s fully shaped”. But the backend sent { profile: { settings: null } } — and your Profile interface declared settings: Settings, not settings: Settings | null. TypeScript happily accepted it because null satisfies any object type when strictNullChecks is off — or worse, when you used as any somewhere upstream and never noticed.

That ?. wasn’t protecting you from bad data. It was papering over a broken agreement between your code and the world outside it.

This isn’t about TypeScript being “weak”. It’s about how we use it — or rather, how we don’t. We treat types like documentation: nice to have, easy to skim, rarely enforced. But TypeScript doesn’t document intent. It enforces contracts — if you write them, ship them, and guard them at boundaries. When we skip that work, we don’t get “type safety”. We get type theater: a convincing performance with no real accountability.

I’ve made this mistake on every kind of project I’ve touched:

  • A side project where I typed an API response as User[], shipped it, then watched users vanish from the dashboard because the backend started returning { users: User[] } — and my as User[] cast silently dropped the wrapper.
  • A healthcare SaaS startup where we modeled PaymentMethod.type as string, accepted "card", "ach", and later "paypal" from a misconfigured staging environment — crashing checkout because our UI expected only two values and had no fallback.
  • A freelance logistics app where inconsistent status strings ("DELIVERED", "delivered", null, missing entirely) lived in status: string, and we sprinkled .toLowerCase() everywhere instead of fixing the contract at ingestion.

Every time, the fix wasn’t more types. It was clearer intent, narrower constraints, and deliberate enforcement at the edges.

Let’s talk about how to do that — without abstractions, without libraries, without waiting for “perfect tooling”.

The Real Problem Isn’t Types — It’s Unenforced Agreements

TypeScript’s biggest failure isn’t its syntax or its learning curve. It’s that teams conflate typing with contracting.

Typing says: “This value has these properties.”

Contracting says: “This value must have these properties — and if it doesn’t, here’s exactly what happens.”

Most TypeScript code lives in the first camp. That’s fine for local variables. It’s dangerous for data crossing boundaries — APIs, forms, localStorage, third-party SDKs.

At the healthcare SaaS startup, we had a billing service client. Its PaymentMethod looked like this:

interface PaymentMethod {

id: string;

type: string;

last4?: string;

}

We wrote business logic assuming type was one of two known strings. Our UI rendered icons based on type === 'card' or type === 'ach'. Everything worked in dev. Then staging got reconfigured — a new payment provider integration accidentally returned "paypal" for type. Our frontend didn’t crash immediately. It rendered a broken icon, failed a feature flag check, and silently fell back to an empty state. Support tickets piled up for two days before someone noticed the network tab showing "paypal".

No TypeScript error. No runtime exception. Just a silent breach of contract — and we’d signed it blindly.

Why? Because string is infinitely permissive. It says “anything that’s a string”, not “one of these specific strings we expect”. And we trusted the type instead of verifying the data.

That’s not TypeScript’s fault. That’s us treating the type system like a suggestion box instead of a negotiation table.

Contracts require three things:

  • Specificity — naming the exact set of valid states
  • Enforcement — checking at the boundary, not downstream
  • Failure behavior — knowing what happens when the contract is broken

Let’s walk through how to build each — with real examples, real tradeoffs, and zero abstraction debt.

3.1 Start with the Data You Actually Receive — Not What You Hope For

A common instinct is to type loosely (“it’s a string”) and normalize later (“we’ll .toLowerCase() it”). That pushes validation into business logic — where it’s duplicated, forgotten, and impossible to audit.

I learned this the hard way on a freelance gig for a local logistics company. Their API returned shipment statuses like this:

| Environment | status value |

|-------------|----------------|

| Dev | "delivered" |

| Staging | "DELIVERED" |

| Production | null |

| QA | (field missing) |

All documented as “string”. So we typed it as status: string. Then added .toLowerCase() before every comparison. Then added ?? 'pending' in some places but not others. Then found a component that crashed because it tried to call .toLowerCase() on null.

Three weeks in, we had five different “normalize status” helpers — none tested, all slightly inconsistent.

The fix wasn’t better normalization. It was narrowing the contract at the boundary.

What We Changed

We stopped typing status as string. Instead, we:

  • Observed all real values across environments
  • Defined a closed set: 'pending' | 'in_transit' | 'delivered' | 'failed'
  • Made absence explicit: null means “not yet assigned”, not “unknown”
  • Wrote one parser — at the API layer — that either returns a valid Shipment or null

Here’s the code we shipped:

// ✅ Constrain to observed values and handle absence explicitly

type ShipmentStatus = 'pending' | 'in_transit' | 'delivered' | 'failed';

interface Shipment {

id: string;

status: ShipmentStatus | null; // null means "not set yet", not "unknown"

updatedAt: string;

}

// ✅ Add runtime validation at boundary (no external deps)

function parseShipment(raw: unknown): Shipment | null {

if (!raw || typeof raw !== 'object') return null;

const { id, status, updatedAt } = raw as Record<string, unknown>;

// id and updatedAt are required

if (typeof id !== 'string' || typeof updatedAt !== 'string') return null;

// status is optional — can be null, undefined, string, or garbage

if (status === null || status === undefined) {

return { id, status: null, updatedAt };

}

if (typeof status !== 'string') return null;

const normalized = status.trim().toLowerCase() as ShipmentStatus;

const validValues: ShipmentStatus[] = ['pending', 'in_transit', 'delivered', 'failed'];

return validValues.includes(normalized)

? { id, status: normalized, updatedAt }

: null;

}

// Usage in API client

async function fetchShipment(id: string): Promise<Shipment | null> {

try {

const res = await fetch(/api/shipments/${id});

const raw = await res.json();

return parseShipment(raw);

} catch {

return null;

}

}

Why This Works

  • Specificity: ShipmentStatus is exhaustive and narrow. No surprises.
  • Enforcement: parseShipment runs once, at the boundary. Invalid data becomes null, not corrupted domain objects.
  • Failure behavior: null is intentional — UIs can show “loading” or “not assigned”, not crash.

Tradeoffs & Reality Checks

  • You must observe real data — not just docs. Run a quick script to collect actual status values from staging logs for a week. If you see "canceled" appear once, add it — or log it and talk to the backend team.
  • Don’t over-normalizestatus.trim().toLowerCase() is safe because whitespace/case variance is known noise, not semantic meaning. If "DELIVERED" and "delivered" mean different things (e.g., one is admin-only), then they’re separate types — don’t merge them.
  • Testing is cheap:

  test('parses lowercase delivered', () => {

expect(parseShipment({ id: '1', status: 'delivered', updatedAt: '2024-01-01' }))

.toEqual({ id: '1', status: 'delivered', updatedAt: '2024-01-01' });

});

test('rejects unknown status', () => {

expect(parseShipment({ id: '1', status: 'shipped', updatedAt: '2024-01-01' }))

.toBeNull();

});

This isn’t “more work”. It’s focused work — done once, at the right layer, with clear ownership.

3.2 Never Trust any, unknown, or as in Shared Code — Especially Not in Utils

any is TypeScript’s escape hatch. unknown is its cautious cousin. as is its handshake. All three are necessary — but when used carelessly in shared utilities, they become liability vectors.

At a mid-sized edtech company, our shared apiClient.ts had this pattern in its Axios interceptor:

// ❌ “Just get it working” anti-pattern — breaks type flow downstream

axios.interceptors.response.use(

(res) => {

// Backend changed response shape from { data: T } to { payload: T }

// So we cast to bypass inference

return (res.data as any).data || res.data;

}

);

It “worked” — until a new endpoint returned { data: { user: { id: 123 } } }, but the old cast hid that data was now nested under payload. Our student dashboard fetched /me and got back { user: { id: 123 } }, but the interceptor returned undefined because res.data.data didn’t exist. The UI rendered nothing. No errors. No Sentry alerts. Just confused teachers and silent drop-offs.

Two weeks passed before QA caught it.

The problem wasn’t the cast. It was that the cast lived in a shared utility, invisible to consumers, and unchecked by tests.

How We Fixed It

We banned as any in PRs — full stop. Then we replaced unsafe casts with one of three patterns:

#### Pattern 1: Explicit Generics + Narrowed Response Shape

Instead of casting res.data, we told Axios exactly what shape to expect:

// ✅ Use explicit generics + narrow response shape

interface ApiResponse<T> {

data: T;

meta?: { count: number };

links?: { next?: string };

}

async function get<T>(url: string): Promise<T> {

const res = await axios.get<ApiResponse<T>>(url);

return res.data.data; // type-safe access, no casting

}

// Usage

const user = await get<User>('/api/users/123'); // type is User, not any

Axios validates the outer shape (ApiResponse), and TypeScript infers T from the generic. No cast needed.

#### Pattern 2: Exhaustive Type Guards (No as)

For untyped inputs (e.g., form submissions, localStorage), we prefer type guards over casting:

// ✅ In utils: prefer exhaustive narrowing over as

function isString(val: unknown): val is string {

return typeof val === 'string' && val.trim() !== '';

}

function isNumber(val: unknown): val is number {

return typeof val === 'number' && !isNaN(val);

}

function formatName(user: unknown): string {

if (!user || typeof user !== 'object') return 'Anonymous';

const { firstName, lastName } = user as Record<string, unknown>;

// Now validate each field before using it

const first = isString(firstName) ? firstName.trim() : '';

const last = isString(lastName) ? lastName.trim() : '';

return [first, last].filter(Boolean).join(' ') || 'Anonymous';

}

Note: user as Record is only to enable property access. We don’t trust its contents — we validate each one.

#### Pattern 3: satisfies for Literal Safety (TS 4.9+)

When you need to assert a literal value without widening:

// ❌ This widens to string

const status = 'delivered' as string; // loses 'delivered' literal type

// ✅ This keeps the literal and verifies it's allowed

const status = 'delivered' satisfies ShipmentStatus; // type is 'delivered'

Use satisfies when initializing constants — not for runtime data.

Why This Matters

  • as any erases all type information. Once it’s in the flow, every consumer inherits the loss.
  • unknown is safe only if you narrow it before use. In shared utils, “narrowing” must be explicit, testable, and fail-fast.
  • Every as should answer: “What fails if this is wrong?” If the answer is “nothing — it just breaks downstream”, it’s a bug waiting to happen.

Practical Rule

In shared utilities, every as must be paired with a type guard or live inside a function whose return type is narrowed and tested. If you can’t write a unit test that fails when the cast is invalid, don’t cast.

3.3 Use Discriminated Unions for State That Changes Behavior — Not Just Enums

Enums and union types are great for listing possible values. But when those values trigger different behavior, flat unions force manual discipline — and humans forget.

At a small startup building a real-time inventory dashboard, our state looked like this:

// ❌ Flat union — forces manual sync across logic branches

interface InventoryState {

status: 'loading' | 'success' | 'error';

items: InventoryItem[];

error?: ApiError;

}

Then our render function did this:

function renderInventory(state: InventoryState) {

if (state.status === 'loading') return <Spinner />;

if (state.status === 'error') return <ErrorBanner error={state.error} />;

return <ItemList items={state.items} />;

}

Looks fine — until you add a new field to error, or rename items to inventoryItems, or forget to update the loading branch when adding skeleton loaders.

Two months in, we had:

  • Loading state rendering stale data (because items wasn’t cleared on refetch)
  • Error state showing outdated messages (because error wasn’t reset on retry)
  • Success state crashing when items was undefined (backend sometimes omitted it)

All because the type didn’t enforce which fields belong to which state.

The Fix: Discriminated Unions

We rewrote InventoryState as a union where each member carries only the data it needs:

// ✅ Discriminated union — compiler enforces exhaustiveness

type InventoryState =

| { status: 'loading' }

| { status: 'success'; items: InventoryItem[] }

| { status: 'error'; error: ApiError };

// ✅ Exhaustive handling — TS errors if you forget a case

function renderInventory(state: InventoryState) {

switch (state.status) {

case 'loading':

return <Spinner />;

case 'success':

return <ItemList items={state.items} />; // items guaranteed

case 'error':

return <ErrorBanner error={state.error} />; // error guaranteed

// ❌ no default — TS catches missing cases

}

}

Now:

  • state.items only exists when state.status === 'success'
  • state.error only exists when state.status === 'error'
  • Adding a new status (e.g., 'refetching') requires updating renderInventory — TS won’t let you forget

Bonus: Derive Helpers Without Duplication

You can still write reusable predicates:

// ✅ Bonus: derive loading state without duplication

function isLoading(state: InventoryState): state is { status: 'loading' } {

return state.status === 'loading';

}

function isSuccess(state: InventoryState): state is { status: 'success'; items: InventoryItem[] } {

return state.status === 'success';

}

// Usage in hooks

useEffect(() => {

if (isSuccess(inventoryState)) {

trackEvent('inventory_loaded', { count: inventoryState.items.length });

}

}, [inventoryState]);

When to Use This

  • Any state that controls rendering branches (loading/success/error)
  • Form states ('idle' | 'submitting' | 'submitted' | 'failed')
  • Feature flags with varying payloads ({ enabled: true; config: FeatureConfig } | { enabled: false })

Tradeoff Note

Discriminated unions make types larger — but they make logic smaller and safer. You trade a few extra lines of type definition for eliminated null checks, fewer bugs, and automatic enforcement. In practice, the net LOC change is neutral or negative.

4. Common Pitfalls — And How to Avoid Them

These aren’t edge cases. They’re daily landmines — and they all stem from treating types as documentation instead of contracts.

4.1 Using interface for Everything — Even When You Need Runtime Identity

Interfaces are erased at runtime. That’s great for pure data modeling. It’s terrible when you need to tell two similar-looking objects apart.

At an e-commerce client, we defined:

// ❌ Interface — erased at runtime, zero identity

interface Product {

id: number;

name: string;

price: number;

}

Then reused Product for:

  • API responses (GET /products/123)
  • Form inputs ()
  • Database models (Prisma)
  • LocalStorage cache

Later, we needed to serialize only certain fields before POST — but couldn’t tell which Product came from the API vs. the form. Both were Product. No instanceof. No brand. No way to distinguish.

We tried Object.prototype.toString.call(product) — but it returned [object Object] for both.

The Fix: Branded Types

Branded types add a compile-time and runtime marker — without classes, without overhead:

// ✅ Branded types for lightweight identity

type ApiProduct = Product & { __brand: 'api' };

type FormProduct = Product & { __brand: 'form' };

type DbProduct = Product & { __brand: 'db' };

function createApiProduct(p: Product): ApiProduct {

return { ...p, __brand: 'api' } as ApiProduct;

}

function createFormProduct(p: Product): FormProduct {

return { ...p, __brand: 'form' } as FormProduct;

}

function isApiProduct(x: unknown): x is ApiProduct {

return typeof x === 'object' && x !== null && '__brand' in x && (x as any).__brand === 'api';

}

function isFormProduct(x: unknown): x is FormProduct {

return typeof x === 'object' && x !== null && '__brand' in x && (x as any).__brand === 'form';

}

Now, serialization logic can branch safely:

function serializeForApi(product: ApiProduct) {

return { id: product.id, name: product.name }; // omit price — backend calculates it

}

function serializeForForm(product: FormProduct) {

return { ...product, price: product.price.toString() }; // format for input

}

Why Not Classes?

Classes work — but they imply behavior. If Product has no methods, no inheritance, no lifecycle, a class adds bundle size, constructor overhead, and confusion (“Why does this data object have a constructor?”). Branded types give you runtime identity with zero runtime cost.

4.2 Ignoring --strictNullChecks Until “Later” — Then Paying for It in QA

This is the most widespread, most expensive mistake I see.

Early-stage SaaS. Team of 8. tsconfig.json had:

{

"compilerOptions": {

"strict": false,

"strictNullChecks": false

}

}

“We’ll enable strict mode when we scale,” they said.

Six months in, 40% of NPE bugs traced back to optional props assumed non-null:

interface User {

profile?: { avatarUrl?: string };

}

function renderAvatar(user: User) {

return <img src={user.profile.avatarUrl} />; // crashes if profile is undefined

}

With strictNullChecks: false, TypeScript lets this compile. With it on, you get TS2532: Object is possibly 'undefined' — immediately.

Fixing it required auditing every optional chain across 120+ components. Took three developers two days. All preventable.

The Fix: Enable strictNullChecks Day One

Not “eventually”. Not “next sprint”. Now.

If your codebase can’t compile with it on, that’s not a TypeScript problem — it’s a data contract problem. Each error is a place where you’re assuming data exists but haven’t verified it.

Start here:

# Add to tsconfig.json

{

"compilerOptions": {

"strictNullChecks": true,

"strict": true

}

}

Then fix errors in order of impact:

  • API responses: Add | null or | undefined to optional fields. Write parsers.
  • Props: Mark optional props as prop?: Type — then handle absence in the component.
  • Local state: Use useState instead of useState with initial undefined.

What You’ll Hear (And Why It’s Wrong)

  • “It slows us down.” → It slows down bug discovery. You’ll spend more time debugging runtime NPEs than fixing compile errors.
  • “Our backend guarantees it’s there.” → Does your staging env guarantee it? Your mobile SDK? Your offline cache? Contracts are about observed reality, not promises.
  • “We’ll add ESLint rules instead.” → ESLint can’t catch user.profile.avatarUrl if profile is typed as Profile (not Profile | null). Only the type system can.

Enabling strictNullChecks is the single highest-leverage TypeScript setting. Do it before your first PR.

4.3 Treating Types as Immutable — Then Being Surprised When They Change

Types evolve. APIs change. Frontend requirements shift. If your types are brittle — tightly coupled to one version of a response — you’ll fight every change.

At a logistics startup, our Shipment interface looked like this:

interface Shipment {

id: string;

status: ShipmentStatus;

driver: {

name: string;

phone: string;

};

vehicle: {

licensePlate: string;

make: string;

};

}

Then the backend added driver.photoUrl and vehicle.color. We updated the interface — but forgot to update the parsing logic that built Shipment objects from raw JSON. So photoUrl was undefined, and color crashed the UI.

The issue wasn’t the new fields. It was that our type assumed completeness — but our parser didn’t enforce it.

The Fix: Parse First, Type Later

Never let raw data touch your domain types directly. Always go through a parser that:

  • Accepts only the fields you need
  • Rejects unknown fields (or logs them)
  • Provides defaults for missing optional fields
function parseShipment(raw: unknown): Shipment | null {

if (!raw || typeof raw !== 'object') return null;

const { id, status, driver, vehicle } = raw as Record<string, unknown>;

if (typeof id !== 'string' || !isValidStatus(status)) return null;

// Driver is optional — but if present, must have name

let parsedDriver: Shipment['driver'] | null = null;

if (driver && typeof driver === 'object') {

const { name, phone } = driver as Record<string, unknown>;

if (isString(name)) {

parsedDriver = {

name,

phone: isString(phone) ? phone : ''

};

}

}

// Vehicle is optional — same logic

let parsedVehicle: Shipment['vehicle'] | null = null;

if (vehicle && typeof vehicle === 'object') {

const { licensePlate, make, color } = vehicle as Record<string, unknown>;

if (isString(licensePlate) && isString(make)) {

parsedVehicle = {

licensePlate,

make,

color: isString(color) ? color : 'unknown'

};

}

}

return { id, status, driver: parsedDriver, vehicle: parsedVehicle };

}

Now:

  • New fields (driver.photoUrl) are ignored — no crash, no leak
  • Missing fields (vehicle.color) get defaults — no undefined access
  • Typos in field names (drivr) fail parsing — not runtime

This is defensive typing: your types describe the ideal, but your parser describes the reality — and bridges the gap.

5. Real Tradeoffs — Not Just Ideals

None of this is free. Let’s name the costs — so you can decide where to pay them.

Tradeoff 1: Parsing Overhead vs. Runtime Safety

Adding parseShipment means every API call does extra work. Is it worth it?

  • Cost: ~0.1ms per parse (for typical payloads)
  • Benefit: No more “cannot read property X of undefined” in production
  • Decision rule: If your app handles user-facing state (forms, dashboards, payments), pay it. If it’s a CLI tool or internal script, maybe skip — but document why.

Tradeoff 2: Type Verbosity vs. Maintainability

Discriminated unions and branded types make types longer. Is that acceptable?

  • Cost: More lines in type definitions
  • Benefit: Fewer bugs, less testing surface, automatic enforcement
  • Decision rule: If the type is used in >3 files or affects user experience, verbosity pays for itself in week one.

Tradeoff 3: Strict Mode vs. Velocity

Enabling strictNullChecks breaks builds. Does that slow you down?

  • Cost: 1–2 hours of fixing existing code
  • Benefit: Zero NPE-related production incidents for the life of the project
  • Decision rule: Do it before your first deploy. Not after.

Tradeoff 4: Runtime Validation vs. Bundle Size

Do you need a full validation library like Zod? Probably not.

  • Cost of Zod: +8–12kb gzipped
  • Cost of hand-rolled parsers: ~0kb, 20–50 lines per type
  • Decision rule: Start with hand-rolled. If you find yourself writing the same guard 5+ times, then extract a tiny util — but keep it focused. Don’t reach for Zod until you need schema evolution or complex async validation.

6. What This Actually Looks Like in Practice

Let’s tie it together with a real, shippable example: a login form.

The Problem

  • Backend returns { success: true, user: { id, email } } on success
  • Returns { success: false, error: 'invalid_credentials' } on failure
  • Sometimes returns { success: true, user: null } (buggy backend)
  • Our old code: const res = await login(); return res.user.email; — crashes on null

The Contract-Negotiated Solution

// ✅ Step 1: Define the exact shapes we accept

type LoginSuccess = {

success: true;

user: { id: string; email: string };

};

type LoginFailure = {

success: false;

error: string;

};

type LoginResponse = LoginSuccess | LoginFailure;

// ✅ Step 2: Parse at boundary

function parseLoginResponse(raw: unknown): LoginResponse | null {

if (!raw || typeof raw !== 'object') return null;

const { success } = raw as Record<string, unknown>;

if (success === true) {

const { user } = raw as Record<string, unknown>;

if (user && typeof user === 'object') {

const { id, email } = user as Record<string, unknown>;

if (typeof id === 'string' && typeof email === 'string') {

return { success: true, user: { id, email } };

}

}

// User is null or malformed — treat as failure

return { success: false, error: 'invalid_user_data' };

}

if (success === false) {

const { error } = raw as Record<string, unknown>;

return { success: false, error: typeof error === 'string' ? error : 'unknown_error' };

}

return null;

}

// ✅ Step 3: Use discriminated union in UI

async function handleLogin() {

const raw = await api.login(credentials);

const result = parseLoginResponse(raw);

if (!result) {

setError('Network error');

return;

}

if (result.success) {

setUser(result.user); // user is guaranteed to exist

navigate('/dashboard');

} else {

setError(result.error); // error is guaranteed to exist

}

}

No any. No as. No optional chaining. Just clear contracts, enforced at the boundary.

Final Thought: TypeScript Doesn’t Prevent Bugs — It Exposes Mismatches

The goal isn’t “zero TypeScript errors”. It’s “zero surprises when reality diverges from expectation”.

Every time you write status: string, you’re saying “I accept any string — and I’ll handle all of them correctly”. Are you sure?

Every time you write as User[], you’re saying “I guarantee this shape matches User[] — and if it doesn’t, I accept the consequences”. Do you?

TypeScript won’t stop you from lying. But it will hold you to your lies — if you write them down honestly.

So write narrow types. Guard at boundaries. Prefer unknown over any. Enable strictNullChecks. Treat every as as a debt that accrues interest.

You’re not writing types to please the compiler.

You’re negotiating contracts with the real world.

Sign them carefully.