TypeScript / Advanced / Lesson 14
Runtime validation and domain values
Start untrusted input at unknown, validate complete accepted shapes and resource limits, and construct a selected domain representation rather than asserting trust.
On this page
Lesson goals
- Validate unknown primitives, objects, and arrays.
- Separate shape validation from numeric and authorization rules.
- Return owned selected data or a documented failure.
Unknown records a missing trust decision
External JSON, storage, messages, and JavaScript dependencies can disagree with a declared TypeScript interface. Assigning a parse result to unknown prevents immediate unchecked property use but does not perform validation by itself. A decoder should inspect required fields and relevant domain rules before constructing accepted data. Distinguish malformed syntax from valid JSON with the wrong shape. JSON.parse can produce any JSON value, including null, arrays, strings, and numbers. Never promise that a generic parse<T> implementation has discovered T at runtime when it merely returns an asserted result. Types are erased and cannot supply that schema automatically.
Inspect actual fields and construct the public result
The first decoder rejects null and non-objects before testing for count. The in check makes the property accessible as unknown in this compiler baseline, and the numeric checks establish a safe accepted range. Returning a new object selects only the count field, so an extra private property is not copied. This is an ordinary-data validator for synthetic parsed JSON, not a promise to neutralize arbitrary proxies or hostile property getters. When an application accepts richer objects, property access effects and prototype policy require additional design. Decide whether extra keys are rejected or ignored and document that choice.
export {};
type CountData = { count: number };
function decode(value: unknown): CountData | undefined {
if (typeof value !== "object" || value === null || !("count" in value)) { return undefined; }
const count = value.count;
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0 || count > 100) { return undefined; }
return { count };
}
console.log(JSON.stringify(decode({ count: 3, privateNote: "omit" })));
console.log(decode({ count: "3" }) === undefined);
{"count":3}
true
The valid numeric count is copied into the output. A numeric-looking string is rejected rather than coerced or asserted.
Validate every element and bound total work
An array check establishes array identity but not a trustworthy element domain. The second decoder validates a small bounded list, requires every name to be a nonempty string within a length limit, and builds a new array. It does not mutate the caller array or retain its element container. The explicit unknown local keeps each item check visible even though library array detection is broad. For nested structures, impose practical depth and size limits before recursive work. A memory-safe language and a successful type check do not prevent denial of service through huge valid input.
export {};
function names(value: unknown): string[] | undefined {
if (!Array.isArray(value) || value.length > 10) { return undefined; }
const output: string[] = [];
for (const item of value) {
const candidate: unknown = item;
if (typeof candidate !== "string" || candidate.length === 0 || candidate.length > 40) { return undefined; }
output.push(candidate);
}
return output;
}
console.log(JSON.stringify(names(["Mira", "Omar"])));
console.log(names(["Mira", 2]) === undefined);
["Mira","Omar"]
true
Both valid strings are copied. A number in the second list causes rejection of the whole input.
Keep validation, normalization, and permission distinct
Validation decides whether data satisfies a contract. Normalization deliberately changes accepted data, such as trimming names or interpreting a numeric string, and needs its own policy. Authorization decides whether the current actor may perform the operation, which a structurally valid payload does not prove. Avoid a validator that silently strips important failures through coercion, defaults, or broad truthiness unless that behavior is documented. Errors should identify a safe field path or category without echoing secrets. Reuse approved schema tooling when appropriate, but evaluate its actual runtime behavior and version instead of assuming inferred types guarantee every desired rule.
Preserve validity after the boundary check
A validated mutable object can later become invalid through aliases. Select or copy the required fields, restrict mutation, or encapsulate transitions that preserve the domain invariant. readonly is useful documentation and checking but not an absolute runtime freeze. A class or factory can make supported construction deliberate, while untyped callers still need checked entry points. Test null, wrong primitives, missing fields, invalid numbers, extra keys, empty lists, and maximum sizes. Keep decoder tests separate from consumer logic so a broken predicate cannot make all downstream tests appear valid through static narrowing. Trust should be earned at a real boundary and maintained by an explicit mutation policy.
Common errors and fixes
A generic JSON parser claims to validate T.
- Why it happens
- An erased type parameter was treated as a runtime schema.
- How to fix it
- Accept unknown and execute a real decoder.
- Prevent it next time
- Test wrong shapes rather than only syntax errors.
A checked number still violates domain rules.
- Why it happens
- typeof was the only numeric validation.
- How to fix it
- Check finiteness, safe integers, and bounds as required.
- Prevent it next time
- Write domain rules explicitly beside the schema.
Valid data becomes invalid after a shared mutation.
- Why it happens
- Validation did not establish a stable ownership or mutation policy.
- How to fix it
- Copy selected data or encapsulate supported transitions.
- Prevent it next time
- Review aliases after validation.
Check your understanding
1. Classify decode(null), decode({count: 2.5}), and decode({count: 0}). Explain each relevant check.
Show worked solution
null is rejected before property access, the fraction fails Number.isSafeInteger, and zero is accepted because the range includes its lower endpoint. A truthiness check on count would incorrectly reject zero. Test these cases as separate requirements so a refactor cannot replace explicit checks with a superficially shorter but incorrect condition.
2. An endpoint accepts the valid CountData shape but permits changing another userβs record. Explain the missing protection without changing the type to any or adding assertions.
Show worked solution
The missing protection is authorization tied to the authenticated actor and target record. Shape and numeric validation establish only data validity. Enforce permission at the operation boundary before committing the change, and test an actor without access. No more elaborate TypeScript type can independently prove that a request is authorized in the current runtime context.
Key takeaways
- Unknown requires evidence, not an assertion.
- Validate shape, domain, and resource limits explicitly.
- Authorization and stable mutation are separate obligations.