Parsing is only the first boundary
JSON.parse can succeed and produce null, a list, a number or an object not satisfying the business model. An as Command assertion does not inspect those values. Retaining the result as unknown prevents other code from directly using fields it has no reason to trust yet.
Review starts with the observable contract: which shapes are accepted, which are rejected, and when may effects begin? In the example, retry needs an id containing useful text and a bounded numeric limit. The string "3" is not accepted as a number for convenience. That decision avoids hiding a contract difference through silent coercion.
Existence, type and domain rule
Before applying in to unknown, establish a nonnull object. typeof value === "object" is insufficient because null also produces that result. If the domain requires a record rather than an array, that is another check. Then id’s existence permits accessing the field, but does not yet establish string.
Each rule adds a distinct guarantee. A number may be fractional, NaN or outside the permitted range; typeof does not check those properties. In the example, Number.isInteger and inclusive bounds complete the limit rule. For id, checking rejects text without useful characters while preserving the original value, without changing identity through implicit normalization.
A predicate’s promise must be established
An isAccount(x): x is Account function tells callers that the true branch may use x as Account. The compiler uses that promise but does not generate the necessary checks from the interface. A body always returning true can create misplaced confidence even when its signature passes checking.
Review every true-return path against required fields, variants and domain rules. Also consider the false result: a predicate accepting only a legitimate subset of the type may teach callers an incorrect exclusion. When the rule is a narrower business validation, an explicit result carrying projected data can be clearer than a general predicate.
Assertions that interrupt control flow
An assertText(x): asserts x is string function promises that normal return permits continuing with textual x. The body must prevent that return when the promise is false, for example by throwing TypeError. An empty body does not establish the guarantee. Returning false is a boolean predicate’s protocol, not this assertion signature’s protocol.
The rule must match its declared scope. If the contract requires only string, an empty string is valid; nonempty text needs another check and a coherent description. Likewise, annotating a coordinator’s return as Result does not stop an assertion it calls from throwing. The coordinator must handle that interruption if it promises to represent it in a result branch.
Failures also cross type boundaries
JavaScript permits throwing values that are not Error. With useUnknownInCatchVariables, catch requires checking the value before reading properties. instanceof Error allows handling an instance recognized in that context, but other values still need a policy. Applying as Error does not create message on a thrown string.
Failure state must survive handling. Returning success with an empty list after every exception makes a completed empty query indistinguishable from a query that did not finish. A stable failure code can be published while details stay in controlled internal records. Arbitrary text thrown by a dependency need not be exposed to communicate a useful outcome.
Classify the stage that actually failed
A single try containing parsing, validation and delivery can catch failures with very different causes. If catch always returns invalid-json, integration unavailability becomes attributed to the request. Users may try to correct valid data while the real problem remains untreated.
Bound stages according to outcomes the consumer needs to distinguish. Parsing can produce invalid-json; field checks can produce invalid-command; a later operation can fail for another reason. This separation does not require publishing internal details. It requires each code to be supported by the observed scope and effects to begin only after an accepted command exists.
Guided reading of an accepted command
The example executes no commands. Its try contains only JSON.parse; remaining flow checks shape, id and variant. stop needs no limit. retry requires an integer from 0 through 5. Unknown kind reaches invalid-command. Accepted returns construct new objects with intended fields, so unknown fields are not propagated.
Read that policy precisely: unknown fields are ignored, rather than causing failure. Whitespace-only id is rejected, but text with surrounding whitespace is retained. Input is JSON parsed in this context, not an arbitrary object with getters or a Proxy. If the boundary changes, those premises also need review.
// TypeScript 7.0; strict: true; ES2022 library. Input is JSON text, not arbitrary objects.
type Command =
| { kind: "retry"; id: string; limit: number }
| { kind: "stop"; id: string };
type DecodeResult =
| { ok: true; command: Command }
| { ok: false; reason: "invalid-json" | "invalid-command" };
function decodeCommand(text: string): DecodeResult {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return { ok: false, reason: "invalid-json" };
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return { ok: false, reason: "invalid-command" };
}
if (!("kind" in raw) || !("id" in raw) ||
typeof raw.id !== "string" || raw.id.trim().length === 0) {
return { ok: false, reason: "invalid-command" };
}
if (raw.kind === "stop") {
return { ok: true, command: { kind: "stop", id: raw.id } };
}
if (raw.kind === "retry" && "limit" in raw &&
typeof raw.limit === "number" && Number.isInteger(raw.limit) &&
raw.limit >= 0 && raw.limit <= 5) {
return { ok: true, command: { kind: "retry", id: raw.id, limit: raw.limit } };
}
return { ok: false, reason: "invalid-command" };
}Trace malformed JSON, retry with a textual limit, retry with limit zero, and stop with valid id. Only the parser is inside try. Unknown fields are ignored during projection; id is preserved and only empty or whitespace-only text is rejected.
Common pitfalls
Trusting as or satisfies over any; declaring a predicate without validation; reading message in every catch; classifying delivery failure as invalid JSON.
Related topics: Static types and runtime values · Unions, narrowing and state models · Configuration, delivery and migration
Types retain guarantees established at the boundary. Keep validation and effects separate, and communicate failure of the stage that actually failed.
Reference: TypeScript Handbook: Everyday Types · TypeScript 7.0; explicit strict and host configuration