noctcore-contracts/require-schema-parse-at-boundary
Parse external boundary data at runtime — don’t assert its shape with
as T.
Recommended preset: off · Autofix: no · Suggestions: no · Type information: not needed
External data — a fetch body, a JSON.parse result, a message-event payload — has whatever shape the
sender actually sent, which TypeScript never sees. Asserting it with as User is a compile-time
promise the runtime never checks: a field the server dropped is now undefined masquerading as a
string, and the corruption surfaces far from the boundary. Parsing with a runtime schema
(zod/valibot) validates the shape at the edge and fails loudly there:
const user = UserSchema.parse(await res.json()); // validatedWhat it flags
Section titled “What it flags”This is a conservative syntactic slice of a concept that is fully general only with type
information. It flags a cast whose target is a shape claim (a named type, an array, or a union
containing one: as User, as User[], as User | null) applied to a boundary read:
const user = JSON.parse(raw) as User;const users = JSON.parse(raw) as User[];const fetched = (await res.json()) as User;const user = UserSchema.parse(JSON.parse(raw));const users = UserSchema.array().parse(JSON.parse(raw));const fetched = UserSchema.parse(await res.json());The boundary reads it knows:
| Source | Example |
|---|---|
JSON.parse(...) |
covers web storage and OpenAI call.function.arguments fed to it |
await <expr>.json() |
a fetch Response body |
localStorage.getItem / sessionStorage.getItem |
also through window. / globalThis. |
URLSearchParams .get / .getAll |
new URLSearchParams(...), <x>.searchParams, a searchParams binding, or a const bound to one of those |
event.data in a message listener |
x.addEventListener('message', fn), x.onmessage = fn, onmessage = fn, including a { data } parameter |
Anthropic tool_use .input |
block.input inside if (block.type === 'tool_use'), a ?: / && guard, case 'tool_use':, a .find((b) => b.type === 'tool_use') result, or a .filter(...) of that shape followed by .map / .flatMap / .forEach |
boundaries option |
any callee you list |
const prefs = JSON.parse(localStorage.getItem('prefs') ?? '{}') as Prefs;const sort = new URLSearchParams(location.search).get('sort') as Sort;window.addEventListener('message', (event) => handle(event.data as Message));const args = JSON.parse(call.function.arguments) as WeatherArgs;if (block.type === 'tool_use') run(block.input as WeatherArgs);const prefs = PrefsSchema.parse(JSON.parse(localStorage.getItem('prefs') ?? '{}'));const sort = SortSchema.parse(new URLSearchParams(location.search).get('sort'));window.addEventListener('message', (event) => handle(MessageSchema.parse(event.data)));const args = WeatherArgsSchema.parse(JSON.parse(call.function.arguments));if (block.type === 'tool_use') run(WeatherArgsSchema.parse(block.input));Through a const
Section titled “Through a const”A boundary value stored in a const and cast later in the same function is flagged too:
async function loadUser(res: Response) { const raw = await res.json(); return raw as User;}async function loadUser(res: Response) { const raw = await res.json(); return UserSchema.parse(raw);}The binding is followed only when nothing could have checked the value first, so the rule gives the benefit of the doubt whenever it cannot prove otherwise:
- Only a single
const name = <boundary>is followed, neverlet,var, a destructuring pattern or a parameter. Chains of such consts (const body = await res.json(); const raw = body;) are followed. - The cast must be in the same function as the declaration.
- Any read of the binding before the cast other than another
ascast (a guard likeisUser(raw), anassertUser(raw)call, an'id' in rawcheck, passing it to a function, mutating a property) keeps the rule silent, as does any read from a nested function. Reads after the cast do not excuse it.
async function loadUser(res: Response) { const raw = await res.json(); assertUser(raw); // might have validated it: not flagged return raw as User;}What it does not flag
Section titled “What it does not flag”- Casts to
unknown,any,constor a primitive keyword (as string | null): the safe or neutral forms. satisfies, which checks the value against the type instead of asserting it.- A cast of the parse result (
UserSchema.parse(raw) as User): the schema already checked it. - Look-alikes that are not boundary reads:
cache.getItem(...),map.get(...),headers.get(...),.dataoutside amessagelistener,.inputwithout atool_useguard. - A boundary value that is neither cast directly nor bound by a followed
const(a property of an object, a function return value). That needs a type-aware setup.
Options
Section titled “Options”| Option | Type | Default | Meaning |
|---|---|---|---|
boundaries |
string[] |
[] |
Extra callees whose result is boundary data: a bare name or a dotted path. |
'noctcore-contracts/require-schema-parse-at-boundary': ['error', { // `readBody(event) as T` and `(await readBody(event)) as T` are then flagged. boundaries: ['readBody', 'ipcRenderer.invoke'],}]// rule options: {"boundaries":["readBody"]}const body = (await readBody(event)) as Body;const body = BodySchema.parse(await readBody(event));When not to use it
Section titled “When not to use it”If you deliberately trust certain boundaries (an internal service with a shared type package) or have
a type-aware lint pass that supersedes this heuristic, leave this rule off. It ships off in the
recommended preset for that reason — enable it where the boundary is genuinely untrusted.