When your TypeScript compiles but your types are still wrong

By Sven Lindqvist · 20 July 202631 views
When your TypeScript compiles but your types are still wrong

A developer at a logistics company sees zero TypeScript errors in her CI pipeline. The code compiles cleanly across three environments. She ships. A user reports that filtering shipments by date range returns no results. She investigates: the filter function is comparing a Date object with a string — the type of the date field was declared as Date, but the API returns strings in ISO 8601 format. TypeScript cannot detect this mismatch because the API response was typed with as at the boundary, and the compiler accepted the assertion without checking whether it reflected reality.

// What TypeScript sees:
interface Shipment {
  id: string;
  deliveryDate: Date;  // TypeScript believes this is a Date object
  status: 'pending' | 'delivered' | 'failed';
}

// What actually comes from the API:
// { id: "sh-123", deliveryDate: "2024-03-15T10:30:00Z", status: "delivered" }

// The broken assertion:
const shipments = await fetch('/api/shipments').then(r => r.json() as Shipment[]);
// shipments[0].deliveryDate is the string "2024-03-15T10:30:00Z", not a Date object
// TypeScript thinks it is a Date — the assertion made it so

// The filter that fails silently:
const recent = shipments.filter(s => s.deliveryDate > new Date(Date.now() - 7 * 86400000));
// Comparing string > Date — always false in JavaScript
// Returns empty array, no error thrown, no TypeScript warning

Zero errors does not mean zero bugs. TypeScript's compile-time guarantees extend only as far as the type information is correct. When types are wrong at the boundary between TypeScript and the outside world, the compiler has nothing to check against — it enforces consistency with the wrong types faithfully, which is not safety.

The boundary problem

TypeScript types are erased at runtime. A variable typed as Date at compile time might be a string at runtime if the type was asserted incorrectly. The boundary between TypeScript's type system and external data — API responses, localStorage, URL parameters, database query results, browser events, environment variables — is where most runtime type errors originate in TypeScript codebases.

External data comes from systems that do not speak TypeScript. REST APIs return JSON. JSON does not have a Date type — it has strings. JSON does not have a Set — it has arrays. JSON does not distinguish between null and absent. Every transformation from JSON to TypeScript types requires code that runs at runtime, not just type annotations that the compiler accepts.

// The pattern that creates silent runtime failures

// 1. The 'as T' assertion — tells TypeScript to trust the type without checking
const user = await fetchUser(id) as User;
// If the API returns { id: 1 } instead of { id: "1" }, TypeScript cannot tell

// 2. The 'as any' escape — removes all type checking entirely
const config = JSON.parse(localStorage.getItem('config') || '{}') as any;
config.maxRetries.toString();  // No error in TypeScript, crashes if maxRetries is undefined

// 3. The type annotation without runtime validation
function processWebhook(body: WebhookPayload): void {
  // body is 'WebhookPayload' according to TypeScript
  // body is whatever the webhook sender chose to send at runtime
  if (body.event === 'payment.succeeded') {
    processPayment(body.paymentId);  // paymentId might not exist — webhook schema drift
  }
}

The right approach: validate at the boundary

The fix is to make runtime validation and TypeScript types derived from the same source of truth. Zod is the most common library for this, but the principle applies regardless of the library used.

import { z } from 'zod';

// Define the schema — this is the source of truth for both runtime validation
// and the TypeScript type
const ShipmentSchema = z.object({
  id: z.string(),
  deliveryDate: z.string().datetime().transform(str => new Date(str)),
  // .transform converts the ISO string to a Date object at parse time
  // The resulting type is Date — matching what the application code expects
  status: z.enum(['pending', 'delivered', 'failed']),
});

// Derive the TypeScript type from the schema — not the other way around
type Shipment = z.infer<typeof ShipmentSchema>;
// Shipment.deliveryDate is Date — and it is actually a Date at runtime

// Parse at the boundary — throws at runtime if the data does not match
async function fetchShipments(): Promise<Shipment[]> {
  const response = await fetch('/api/shipments');
  const data = await response.json();
  return z.array(ShipmentSchema).parse(data);
  // If the API returns strings where dates are expected, parse() throws with a
  // detailed error message — instead of silently returning wrong data
}

// Now the filter works correctly
const recent = shipments.filter(s => s.deliveryDate > new Date(Date.now() - 7 * 86400000));
// s.deliveryDate is a real Date object — the comparison is correct

The critical distinction: as User tells TypeScript "trust me, this is a User." Schema.parse(data) actually checks that the data matches the schema at runtime and throws a structured error if it does not. These are fundamentally different operations. The type annotation says what the developer intended; the parse operation checks what the data actually is.

Type assertions that hide real mismatches

Type assertions have legitimate uses — when the developer has information that TypeScript cannot infer, such as after a type guard or when working with DOM APIs. The assertions that cause problems are those used to bypass a type error that reflects a real mismatch.

// Legitimate use — TypeScript does not know what getElementById returns
const canvas = document.getElementById('main-canvas') as HTMLCanvasElement;
// The developer knows from the HTML that this element is a canvas
// Still safer with a runtime check:
const canvas = document.getElementById('main-canvas');
if (!(canvas instanceof HTMLCanvasElement)) throw new Error('Expected canvas element');

// Problematic use — hiding a genuine type mismatch
interface Config {
  maxRetries: number;
  timeout: number;
}

const rawConfig = JSON.parse(process.env.CONFIG ?? '{}');
const config = rawConfig as Config;  // Asserts the type without checking
config.maxRetries + 1;  // TypeScript: number + 1 = number. Runtime: undefined + 1 = NaN

// The double assertion — always wrong
const value = someFunction() as any as SpecificType;
// This is type laundering — escaping the type system to force any value into any type
// The 'as any' step discards TypeScript's objection; 'as SpecificType' reattaches a type
// If TypeScript requires two assertions to accept the code, the types are wrong

The double assertion (as any as T) is a reliable indicator of a real type mismatch. When TypeScript requires two type assertions to compile — because the single-step assertion would still produce an error — it means the actual type and the target type have no structural overlap. The correct response is to understand why they diverge and fix the underlying type, not to force the assertion.

The any propagation problem

A single any in a codebase does not stay isolated. It propagates through every function that receives it, every variable that stores it, and every function that returns it. The contamination is silent.

// One 'any' at the start
function loadUserPreferences(): any {
  return JSON.parse(localStorage.getItem('preferences') || '{}');
}

// Propagates to every caller
const prefs = loadUserPreferences();  // prefs is 'any'
const theme = prefs.theme;            // theme is 'any'
const fontSize = prefs.fontSize;      // fontSize is 'any'

// Functions that receive 'any' return 'any'
function applyTheme(theme: any): any {
  document.body.className = theme.name;  // No error — 'any' allows any access
  return theme.styles;
}

const styles = applyTheme(theme);      // styles is 'any'
styles.backgroundColor.trim();         // TypeScript: no error. Runtime: depends on the data

Replacing any with unknown requires explicit narrowing before use. unknown is the type-safe alternative to any — it accepts any value, but it forces the developer to check the type before using it.

function loadUserPreferences(): unknown {
  return JSON.parse(localStorage.getItem('preferences') || '{}');
}

const prefs = loadUserPreferences();  // prefs is 'unknown'
prefs.theme;  // Error: Object is of type 'unknown' — must narrow first

// Option 1: Runtime validation with Zod
const PreferencesSchema = z.object({
  theme: z.object({ name: z.string(), styles: z.record(z.string()) }).optional(),
  fontSize: z.number().min(10).max(24).optional(),
});

const prefs = PreferencesSchema.parse(loadUserPreferences());
// prefs.theme is now { name: string; styles: Record<string, string> } | undefined
// TypeScript knows the exact shape

// Option 2: Type guard for simple shapes
function isPreferences(value: unknown): value is UserPreferences {
  return (
    typeof value === 'object' &&
    value !== null &&
    (!('theme' in value) || typeof (value as any).theme === 'string')
  );
}

The unknown approach forces the question at every boundary: "What do I actually know about this value?" any suppresses the question entirely.

Unsound generic inference

TypeScript sometimes infers a type that is wider than intended, producing code that compiles but loses the type information needed for correct behavior.

// TypeScript infers the array type from the first element
const ids = ['user-1', 'user-2', 'user-3'];
// ids is string[], not UserId[] — even if these are user IDs

// The generic constraint that seems to work but does not:
function getProperty<T>(obj: T, key: string): unknown {
  return (obj as any)[key];  // Must use 'any' because key is 'string', not keyof T
}

// The correct version with a proper constraint:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];  // TypeScript knows the return type from the constraint
}

const user = { id: '1', name: 'Alice', role: 'admin' as const };
const name = getProperty(user, 'name');    // TypeScript knows name is string
const role = getProperty(user, 'role');    // TypeScript knows role is 'admin'
const bad  = getProperty(user, 'email');  // TypeScript error: 'email' not a key of user

Wide inference also appears in conditional types and mapped types where the constraint is not tight enough:

// Too wide — T can be anything, constraint is not useful
function serialize<T>(value: T): string {
  return JSON.stringify(value);
}

// Tighter — explicitly constrained to serializable values
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

function serialize(value: JsonValue): string {
  return JSON.stringify(value);
}

// Now a Date object is correctly rejected — Date is not a JsonValue
serialize(new Date());
// Error: Argument of type 'Date' is not assignable to parameter of type 'JsonValue'
// This is a real runtime concern — JSON.stringify(new Date()) returns a string,
// not a Date object, which would be incorrect if the caller expected a Date after parsing

Optional chaining hiding real errors

Optional chaining (?.) is one of TypeScript's most useful features for genuinely nullable values. Used on values that should always be present, it converts a programming error — an uninitialized object — into a silent undefined that propagates downstream.

// Config that should always be initialized before use
function initializeApp() {
  const config = loadConfig();  // Returns AppConfig | undefined on failure

  // DANGEROUS: If loadConfig failed, config is undefined here
  // Optional chaining silently returns undefined instead of throwing
  const maxRetries = config?.settings?.maxRetries;
  const timeout = config?.settings?.timeout;

  // maxRetries and timeout are undefined — no error thrown
  // The application runs with undefined configuration values
  createHttpClient({ maxRetries, timeout });  // maxRetries and timeout are number | undefined
  // HttpClient uses 'undefined' as its retry count — may default to 0 or Infinity
}

// BETTER: Fail loudly when required initialization fails
function initializeApp() {
  const config = loadConfig();
  if (!config) throw new Error('Failed to load app configuration — cannot start');

  // Now TypeScript knows config is AppConfig
  const maxRetries = config.settings.maxRetries;  // number
  const timeout = config.settings.timeout;        // number

  createHttpClient({ maxRetries, timeout });       // Both are number — correct
}

The diagnostic question for ?.: if this expression evaluates to undefined, is that a valid application state or a programming error? If it is a programming error, throw explicitly rather than propagating undefined. Optional chaining defers the error to wherever undefined is first used in a way that causes a failure — which is often far from the original problem and difficult to debug.

The runtime-type-checking pattern for API boundaries

For production code that consumes external APIs, safeParse is often better than parse — it returns a result object rather than throwing, allowing graceful error handling:

async function fetchShipments(): Promise<Shipment[] | ApiError> {
  const response = await fetch('/api/shipments');
  const data = await response.json();

  const result = z.array(ShipmentSchema).safeParse(data);
  if (!result.success) {
    // Log the validation error with details for debugging
    logger.error('Shipment API response schema mismatch', {
      errors: result.error.errors,
      receivedData: data,
    });
    return { type: 'schema_error', message: result.error.message };
  }

  return result.data;  // Shipment[] — validated and correctly typed
}

This approach makes schema drift visible. When the API changes its response format and the TypeScript types are not updated, the Zod schema produces detailed validation errors that identify exactly which fields changed and how. The alternative — silent as T assertions — produces mysterious runtime failures that are difficult to trace to their origin.

Common mistakes

Trusting third-party type definitions. Libraries on DefinitelyTyped (@types/*) are written by contributors, not the library authors. Occasionally they are incorrect or incomplete. When a library function claims to return T but actually returns T | null in certain conditions, the type definition is wrong and TypeScript will not catch errors that depend on the incorrect claim. For critical data paths, validate at runtime rather than trusting third-party types.

Assuming database query results match the schema. ORMs like Prisma generate TypeScript types from the database schema. If a column is NOT NULL in the schema but a migration added it with a default to an existing table, rows created before the migration might have null in that column in practice — even though the type says it is non-nullable. Runtime validation or explicit null checks at the data access layer catch this.

Using as to satisfy TypeScript when refactoring. During a refactor, types that were previously compatible may become incompatible. The instinct is to add as SomeType to silence the compiler and continue. The correct response is to understand why TypeScript is raising the error — it almost always indicates a real semantic mismatch introduced by the refactor.

TypeScript's type system is a tool for expressing intent. When the types expressed do not accurately reflect the runtime reality — because of as assertions, any escape hatches, or missing boundary validation — the compiler's guarantees do not apply to the gap between intent and reality. The patterns that maintain accuracy are the ones that validate external data at the boundary, use unknown instead of any for genuinely unknown data, and derive TypeScript types from validation schemas rather than the other way around.

Comments

No comments yet. Be the first!

Sign in to leave a comment.