Narrow unknown values before use
Type data whose shape has not been established as unknown, then validate it at runtime before reading its properties or assigning it to a narrower type.
Implementation
- Assign values from untyped sources to
unknownimmediately. Several standard APIs returnany, such asJSON.parseand thejson()method of a fetchResponse, so their results need an explicitunknownannotation. - Establish the type with runtime checks, a type-guard function, or a schema validator that owns the boundary.
A type assertion such as
as Orderchanges only what the compiler believes; it does not check the value. - Validate domain constraints as well as types. A number can still be invalid as a count, and a string can still be invalid as an identifier.
- Treat caught errors as
unknown, which is the default under thestrictcompiler option since TypeScript 4.4, and check them before reading properties such asmessage. - Avoid
anyat these boundaries, because it disables the checks that would require validation. When an external library forcesany, contain it in an adapter that returns validated types.
Rationale
TypeScript types are erased at runtime, so a type annotation on external data is only a claim. If the claim is wrong, the program fails later and farther from the source, such as when a missing field is read deep in rendering or business logic. Validating once at the boundary turns an unexpected shape into an immediate, specific error, and lets the rest of the code rely on the types it sees.
Examples
Application: A single value from parsed data
Incorrect (counterexample):
const value: unknown = JSON.parse(responseText);
const count = value as number;
The cast changes the compiler's belief without validating the value, so a string or null flows on as a "number".
Correct:
const value: unknown = JSON.parse(responseText);
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
throw new Error('Expected a non-negative integer count');
}
const count = value;
The runtime check establishes both the type and the domain constraint before use.
Application: An object from a network response
Incorrect (counterexample):
const order = (await response.json()) as { id: string; total: number };
renderTotal(order.total);
If the server omits total or sends it as a string, the error appears inside renderTotal, far from the response.
Correct:
function isOrder(value: unknown): value is { id: string; total: number } {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'total' in value &&
typeof value.total === 'number'
);
}
const body: unknown = await response.json();
if (!isOrder(body)) {
throw new Error('Unexpected order response');
}
renderTotal(body.total);
The type guard checks each field the code relies on. A schema validator that the project already uses at this boundary is an equally good choice.
Application: A caught error
Incorrect (counterexample):
try {
await save();
} catch (error: any) {
showMessage(error.message);
}
A thrown string or object without message produces undefined in the user interface.
Correct:
try {
await save();
} catch (error: unknown) {
showMessage(error instanceof Error ? error.message : String(error));
}
Validation
Inspect each data entry point and check that property access and narrow typing follow a runtime check. Look for tests that pass malformed input and assert the boundary's error.
Replacing any with an unchecked type assertion does not satisfy this rule.
A type assertion inside a type-guard function or schema validator, after the checks that justify it, is not a violation.