TypeScript can approve a program that throws a type error at runtime without the compiler having a bug. That statement surprises developers who treat static checking as proof, but it follows from the language’s goal: describe existing JavaScript patterns while adding useful, erasable checks. Full soundness would reject common idioms, require stronger runtime guarantees, or change how JavaScript values behave.

The practical response is neither to distrust TypeScript nor to pretend its types are proofs. Treat the type checker as a system for tracking assumptions. Some assumptions are established by control-flow analysis. Others enter through explicit escape hatches such as any, assertions, unchecked lookups, mutable substitution, and declaration files. Reliability depends on knowing which is which and preventing unverified claims from flowing deep into the program.

This article maps those trust points and develops safer boundaries around them. It focuses on TypeScript’s deliberate unsoundness, not on nominal domain modeling or generic API technique. The objective is to make every unavoidable assertion small, local, reviewable, and backed by runtime evidence where JavaScript can violate it.

Define What Soundness Would Promise

A sound type system preserves a useful proposition: if an expression is assigned type T, evaluation cannot produce a value outside the runtime meaning of T, assuming the language’s stated rules. The usual preservation shorthand is “well-typed programs do not go wrong,” where “wrong” means a class of failures the type system claims to exclude.

TypeScript cannot make that broad promise. Types are erased, JavaScript code can mutate objects behind the compiler’s view, external data arrives without types, and declaration files describe runtime code without proving it. The compiler also chooses convenient compatibility rules that admit known counterexamples.

Consider a simple lookup:

ts
const capitals: Record<string, string> = {
  VN: 'Hanoi',
  JP: 'Tokyo',
};

const city: string = capitals['XX']; // Accepted without noUncheckedIndexedAccess.
city.toUpperCase();                  // Runtime TypeError: city is undefined.

The annotation says every string key maps to a string, although the runtime object contains two keys. TypeScript interprets the index signature as the programmer’s intended abstraction. With noUncheckedIndexedAccess, the expression becomes string | undefined, moving the assumption back into visible control flow.

Soundness is not binary at the project level. A codebase can have a reliable core even when its edges receive untyped JavaScript, provided the edges validate and narrow before returning values. Conversely, a project with strict: true can be fragile if one any-typed SDK response spreads unchecked through every layer.

Inventory the Ways Trust Enters

Escape hatches differ in visibility and blast radius. An explicit as is searchable. An inaccurate declaration looks ordinary at every call site. An array assignment can be accepted because of a compatibility rule most readers never notice.

Trust point Compiler behavior Typical failure
any Permits almost every operation and assignment Unknown data contaminates downstream expressions
Type assertion Accepts a programmer-selected compatible interpretation Runtime value lacks asserted shape
Non-null assertion Removes null and undefined without a check Timing or input violates the presence assumption
Indexed access May omit the missing-key possibility Array or record lookup returns undefined
Mutable covariance Allows a narrower mutable collection in a broader slot Broader value is inserted into narrower collection
Method bivariance Relaxes callback parameter checking for methods Callback receives a value it cannot handle
Declaration file Treats external description as authoritative Package behavior and .d.ts disagree
Ambient state Assumes globals and platform APIs match configured libraries Runtime environment lacks the declared capability

The key distinction is between evidence-producing operations and evidence-bypassing operations. A check such as typeof value === 'string' gives control-flow analysis evidence. A schema parser can give runtime evidence for an object graph. value as Customer gives no evidence; it tells the compiler to continue as if evidence existed.

Keep a trust ledger during review. For each bypass, ask who establishes the claim, how wide the resulting trusted value travels, and what test or runtime guard would fail if the claim became false. Not every assertion is bad. Some bridge facts the checker cannot express, but their location should reveal the proof obligation.

Contain any, Assertions, and Presence Claims

any is more dangerous than a local missing check because it is contagious. Property reads, calls, arithmetic, and assignments involving any often remain any, allowing an unverified value to acquire a precise annotation later.

ts
declare const response: any;

const retries: number = response.policy.retryCount;
// The assignment is accepted even if retryCount is "many" or missing.

Use unknown for a value whose type has not been established. unknown accepts every incoming value but permits no meaningful operation until code narrows it. Wrap APIs that return any, including loosely typed SDK methods or JSON helpers, so the contagion stops at one adapter:

ts
function parseJson(text: string): unknown {
  return JSON.parse(text) as unknown;
}

The assertion here narrows any to the safer unknown; it does not claim a domain shape. Callers must validate before use.

Type assertions are appropriate when runtime structure has already been checked in a way TypeScript cannot connect to a type, or when adapting a well-specified platform primitive. Prefer one direction of assertion. A double assertion such as value as unknown as Account deliberately defeats compatibility checking and should require exceptional justification.

The non-null operator has the same proof burden. document.querySelector('#save')! says the element exists in every runtime state. A checked helper produces an actionable failure instead:

ts
function requireElement(selector: string): Element {
  const element = document.querySelector(selector);
  if (!element) throw new Error(`Required element not found: ${selector}`);
  return element;
}

Definite-assignment assertions on class fields can hide lifecycle bugs similarly. Prefer constructor initialization, an explicit optional state, or a state transition that checks readiness. If framework injection makes an assertion unavoidable, isolate it in the framework adapter and test the lifecycle that supplies the field.

The satisfies operator is often safer than as for authored constants. It checks compatibility while preserving the expression’s inferred details; it does not reinterpret an incompatible value. It still cannot validate external runtime data.

Make Missing Values Part of the Type

JavaScript arrays and objects are sparse and dynamic. A numeric array access can be out of bounds, a string-keyed record can miss, and a previously present property can be deleted or changed through an alias. TypeScript’s default indexed-access behavior prioritizes convenience and therefore omits some of these possibilities.

Enable noUncheckedIndexedAccess so unproven lookups include undefined. Then choose an API whose type matches its semantics:

  • Use Map.get, which already returns V | undefined, for dynamic key sets.
  • Use a finite object type when every key is known and required.
  • Return T | undefined for optional lookup, or throw from a named require function.
  • Check array length or element presence at the access point; do not assert merely because a previous version of the loop looked safe.

Tuple types can prove access for fixed positions, but arrays remain dynamic. Even after if (values.length > 0), TypeScript may not preserve all facts across aliasing or callbacks, and a later mutation can invalidate them. Capture and check the element rather than claiming global knowledge:

ts
function firstOrThrow(values: readonly string[]): string {
  const first = values[0];
  if (first === undefined) throw new Error('Expected at least one value');
  return first;
}

Optional properties have another edge. Without exactOptionalPropertyTypes, { note?: string } often accepts { note: undefined }, even though “absent” and “present with undefined” can differ for merging, serialization, and property checks. Enabling the option makes writes reflect that distinction unless undefined is explicitly included.

Do not respond to stricter options by adding ! everywhere. That restores the old risk with more punctuation. Model missingness, narrow it, or redesign the lookup so the caller chooses between optional and required behavior.

Understand Variance at Mutable Edges

Some unsoundness exists to preserve JavaScript ergonomics. Mutable arrays are the clearest example. TypeScript permits an array of a subtype to be viewed as an array of a broader type. The broader reference can then insert a value that violates the original array’s promise.

ts
type User = { id: string };
type Administrator = User & { permissions: readonly string[] };

const administrators: Administrator[] = [
  { id: 'a1', permissions: ['deploy'] },
];

const users: User[] = administrators; // Allowed.
users.push({ id: 'u1' });              // Writes a non-administrator.

administrators[1]!.permissions.length; // Runtime failure.

The safer boundary is a readonly view. readonly User[] permits observation but not insertion, so exposing Administrator[] through it cannot corrupt the original collection. Copy before handing data to code that genuinely needs mutation, and keep ownership of mutable collections narrow.

Function parameters have another compatibility edge. With strictFunctionTypes, function-valued properties are checked in the safer direction, but method syntax remains bivariant for historical compatibility. An object method that expects only Administrator may be accepted where callers are allowed to pass any User.

For callback contracts, prefer function properties such as handle: (user: User) => void when parameter safety matters. More broadly, separate read and write capabilities instead of sharing one mutable abstraction. This section is not a complete variance tutorial; the operational point is that mutation plus substitution creates a route around otherwise precise types.

Warning

If an assignment becomes safe only because nobody currently mutates through the broader reference, encode that promise as readonly. A convention is weaker than a type-level capability restriction.

Treat Declarations as Unverified Foreign Code

A .d.ts file is an assertion about runtime behavior. The compiler checks programs against it, not against the JavaScript package, native binding, database, or browser that will actually run. Generated clients and schemas reduce drift but do not eliminate deployment mismatch.

Suppose an SDK declaration says:

ts
declare function loadFlags(): Promise<{
  checkoutEnabled: boolean;
  rolloutPercent: number;
}>;

If an older server returns { checkout_enabled: 1 }, every call site remains well typed and wrong. skipLibCheck does not cause this mismatch, nor does disabling it discover the runtime shape. That option controls checking between declaration files; it cannot verify implementation behavior.

Put foreign declarations behind adapters. At a boundary with meaningful consequence, receive unknown, validate it, normalize version differences, and expose an application-owned type. Pin package and protocol versions, run compatibility tests against supported runtimes, and review declaration changes during upgrades. For a tiny stable platform API, a focused assertion may be proportionate; for network payloads and persisted records, runtime decoding is the safer default.

The same rule applies to your own ambient declarations. Adding declare global { const CONFIG: Config } does not create or validate CONFIG. Bootstrap code must establish it before typed modules depend on it. Prefer an explicit initialization parameter so tests and alternate runtimes cannot accidentally inherit imaginary global state.

Work a Boundary from Unknown to Trusted

Consider a webhook handler that receives a deployment event. An unsafe implementation annotates parsed JSON as DeploymentEvent, reads an indexed environment handler, and asserts that it exists. Three trust gaps collapse into a few clean-looking lines.

A safer adapter establishes each fact separately:

ts
type DeploymentEvent = Readonly<{
  eventId: string;
  environment: 'staging' | 'production';
  revision: number;
}>;

type ParseResult =
  | { ok: true; value: DeploymentEvent }
  | { ok: false; reason: string };

function parseDeploymentEvent(input: unknown): ParseResult {
  if (typeof input !== 'object' || input === null) {
    return { ok: false, reason: 'body must be an object' };
  }

  const value = input as Record<string, unknown>;
  if (typeof value.eventId !== 'string' || value.eventId.length === 0) {
    return { ok: false, reason: 'eventId must be a non-empty string' };
  }
  if (value.environment !== 'staging' && value.environment !== 'production') {
    return { ok: false, reason: 'environment is unsupported' };
  }
  if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) {
    return { ok: false, reason: 'revision must be a non-negative safe integer' };
  }

  return {
    ok: true,
    value: {
      eventId: value.eventId,
      environment: value.environment,
      revision: value.revision as number,
    },
  };
}

const handlers: Record<DeploymentEvent['environment'], (event: DeploymentEvent) => Promise<void>> = {
  staging: deployToStaging,
  production: deployToProduction,
};

async function receiveWebhook(rawBody: string): Promise<Response> {
  let unknownBody: unknown;
  try {
    unknownBody = parseJson(rawBody);
  } catch {
    return new Response('invalid JSON', { status: 400 });
  }

  const parsed = parseDeploymentEvent(unknownBody);
  if (!parsed.ok) return new Response(parsed.reason, { status: 422 });

  await handlers[parsed.value.environment](parsed.value);
  return new Response(null, { status: 202 });
}

The cast to Record<string, unknown> grants only indexed inspection after the object check. Every property remains unknown. The final numeric assertion bridges a limitation after Number.isSafeInteger has supplied runtime evidence. The finite handler record is complete for the validated environment union, so lookup does not require a non-null assertion.

This boundary is intentionally mundane. Its value is that the trusted DeploymentEvent cannot be created from network bytes without passing the parser. Downstream code reasons about deployment policy rather than repeatedly defending against malformed JSON.

Verify Assumptions and Keep the Unsound Core Small

Use compiler options as guardrails: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, useUnknownInCatchVariables, and strictFunctionTypes expose common assumptions. Adopt them deliberately, fixing models rather than mechanically asserting errors away. Lint rules can prohibit explicit any, unsafe member access, and unnecessary assertions, but allow reviewed adapters where interoperation requires them.

Tests must target the runtime claims the compiler cannot prove. Feed decoders nulls, arrays, missing fields, extra fields, wrong primitive types, extreme numbers, and old protocol versions. Property-based generators and fuzzers are useful because boundary failures often hide in combinations humans do not enumerate. Contract tests compare application adapters with real supported SDK or service versions.

Compile-time regression tests cover unwanted assignments and narrowing behavior. Runtime tests cover validators, mutation boundaries, initialization order, and lookup failures. Neither replaces the other. A type test cannot prove JSON shape; a runtime test cannot exhaust every statically rejected call site.

Track escape hatches as concentrated risk. Search for any, non-null assertions, double assertions, ambient declarations, and suppression comments during review. The target is not zero at any cost. The target is a small number of locations where the assumption is named, evidence is nearby, failure is controlled, and trusted output is narrower than untrusted input.

TypeScript is useful precisely because it offers strong local reasoning without requiring JavaScript to become another runtime. Its deliberate unsoundness is manageable when treated as architecture rather than trivia. Make missingness visible, expose readonly capabilities, distrust declarations at foreign boundaries, validate unknown, and force every assertion to answer a simple question: what fact makes this claim true at runtime?