The Type That Was Never Checked
You declare an interface stating that total is a number. You fetch the endpoint, cast the response to that interface, and TypeScript is satisfied. Every editor autocompletes correctly. The build passes.
Then the upstream service changes a serialiser and starts returning "1250.00" as a string. Your code performs arithmetic on it, produces NaN, and renders that to a customer. Nothing in your toolchain objected, because the cast was a claim about what you believed, not a check on what arrived.
This is the single most important thing to understand about static types and external data: static types describe compile-time expectations, and JSON arrives at runtime. A type assertion on parsed JSON is documentation. It has no enforcement power whatsoever. The gap between the two is where an entire category of production bugs lives.
Why This Class of Bug Is Disproportionately Expensive
An unvalidated field does not fail at the boundary where it entered. It propagates.
The wrong value is passed into a calculation, stored in component state, written into a cache, and eventually rendered or persisted. When something finally breaks visibly, the stack trace points at the arithmetic, or the render, or the database write. It does not point at the fetch call three layers up where a string entered a numeric field.
Debugging then proceeds backward through the call chain, and the further the value travelled, the longer that takes. Worse, some of these bugs do not throw at all. Concatenating a number onto a string succeeds. Comparing a numeric string against a number with loose equality succeeds. Sorting strings that look like numbers produces a plausible-looking but wrong order. These produce incorrect output rather than errors, which means they can run in production indefinitely.
What a Contract Actually Needs to Specify
Most hand-written interfaces capture only field names and coarse types. That is the easy half. A contract that prevents real bugs also states the constraints that make a value usable.
Required Versus Optional Versus Nullable
These are three distinct states and conflating them causes real problems. A field may be absent from the payload, present with a null value, or present with a valid value. Code written to handle absence often crashes on explicit null, and vice versa. Say which you mean.
Format, Not Just Type
Knowing a field is a string is nearly worthless. Is it an ISO 8601 timestamp or a Unix epoch rendered as text? Is it an email address? A UUID? A currency amount as a decimal string, deliberately not a float to preserve precision? A string type permits all of these and distinguishes none of them.
Ranges and Enumerations
A status field that can only hold four values should say so. Then an unexpected fifth value is a validation error at the boundary rather than a component silently falling through to a default branch and rendering nothing.
Behaviour on Unknown Fields
Decide explicitly whether extra properties are permitted. Being permissive supports upstream evolution; being strict catches renamed fields. Both are defensible, but choosing by accident is not.
Empty Collection Semantics
Does an empty array mean "no items exist" or "items were not loaded"? These require different handling and are indistinguishable without a stated contract.
A Practical Workflow
Deriving a contract from a real sample is faster and more accurate than writing one from a specification document, because the sample reflects what the service actually sends rather than what the documentation claims.
Step 1: Capture and Format Real Payloads
Take actual responses, not documentation examples. Format them so the structure is legible: nesting depth, array shapes, and which fields are present become visible only once the payload is indented.
Collect several samples covering different states. An empty result, a single item, a large page, and an error response. This is where optionality reveals itself: a field present in every sample but one is optional, and you would never learn that from a single example.
Step 2: Flatten to Find the Real Shape
For deeply nested payloads, converting to a flat tabular form is a fast way to see the full field inventory. Every leaf becomes a dotted path, and comparing several flattened samples side by side makes inconsistencies immediately obvious.
The JSON Manipulation Tools in Develop Box handle both halves of this locally: the formatter makes structure readable, and the JSON to CSV converter flattens nested objects into dotted column paths, which is a surprisingly effective way to diff the shape of two payloads. Because it runs in the browser, you can do this with real production responses without sending them anywhere.
Step 3: Write the Schema, Then Derive Types
The critical ordering point: the runtime schema is the source of truth, and static types are generated from it. Doing this in the other direction leaves you maintaining two definitions that will drift.
Most modern validation libraries support inferring a static type directly from a schema definition, which means one declaration produces both compile-time and runtime guarantees. That is the property you want.
Step 4: Validate Exactly at the Boundary
Parse and validate in one place, immediately where external data enters. Everything downstream then receives a value that is known-good and needs no defensive checks.
The anti-pattern is scattered defensive coding: a null check here, a type coercion there, a fallback default somewhere else. This spreads the same concern across dozens of files, and the checks inevitably become inconsistent. One boundary, one validation, one error path.
Step 5: Fail Usefully
A validation error should say which field failed, what was expected, and what arrived. Log the full path and the received value. This turns a vague upstream problem into an actionable report, and it is what makes contract violations quick to resolve rather than mysterious.
Where to Put Validation
| Boundary | Validate? | Reasoning |
|---|---|---|
| Third-party API response | Always | You have no control over their release schedule or their serialisers. |
| Your own API response | Yes | Deployments are independent. The client may run against an older or newer server. |
| Incoming request body | Always | This is untrusted input. It is a security boundary, not just a correctness one. |
| Uploaded or pasted file | Always | Hand-edited data contains every possible malformation. |
| Local storage or cache read | Yes | It was written by an older version of your own code, with a different shape. |
| Environment configuration | Yes, at startup | Failing fast on boot beats discovering a missing variable during a request. |
| Internal function call | No | Static types already cover this. Runtime checks here are pure overhead. |
The pattern is straightforward: validate where data crosses a trust or deployment boundary, and rely on static types everywhere inside.
Mistakes That Undermine the Whole Exercise
- Coercing instead of rejecting. Silently converting a string to a number hides the upstream problem and lets it persist indefinitely. Coerce only where you have deliberately decided the input format is legitimately flexible, and document that decision.
- Validating in the component. Validation inside a UI component means the same payload is checked differently depending on which screen loaded it. Validate in the data layer, once.
- Duplicating the schema and the type by hand. Two declarations of the same shape will diverge. Generate one from the other.
- Deriving schemas only from success responses. Error and empty states have different shapes and are where most crashes actually occur. Sample them too.
- Swallowing validation failures. Catching the error and falling back to a default converts a loud, diagnosable problem into a quiet, wrong one. That is the exact failure mode you built the contract to eliminate.
Frequently Asked Questions
If I already use TypeScript, why do I need runtime validation?
Because TypeScript types are erased at compile time and enforce nothing at runtime. Casting a parsed response to an interface tells the compiler what you believe; it does not verify what arrived. For external data, the type is a hypothesis and validation is the test.
Should schemas be strict or permissive about extra fields?
Be permissive about additions and strict about the fields you depend on. This lets upstream services add fields without breaking you, while still failing loudly if something you rely on is renamed or removed. For request bodies you receive, be strict, since unexpected fields there can indicate an attack.
Is validation overhead a performance concern?
Rarely. Validation cost is trivial next to the network request that delivered the data. It becomes measurable only on very large payloads in hot paths, and at that point you can validate the structure while deferring deep item-level checks. Measure before optimising it away.
Conclusion: Make the Boundary Explicit
Every application has a line where data stops being external and starts being trusted. In most codebases that line is implicit, which means it is enforced nowhere and assumed everywhere.
Drawing it deliberately, deriving contracts from real payloads, and generating static types from a runtime schema converts a diffuse category of production bugs into a single well-defined error at a known location. The value is not that fewer things go wrong upstream, because upstream will keep changing. It is that when they do, you find out at the boundary instead of in a customer-facing render.
