vercel-labs / vercel-labs/native
JSON.parse `as` cast on a declared optional number misreports the failing path/type (explicit JSON null, 3 levels deep)
Nobody has claimed this yet.
- Dominant language
- Zig
- Stars
- 7.7k
- Forks
- 314
- Avg merge
- 5h
- Merged PRs (30d)
- 13
Description
Summary
A service function whose JSON.parse(...) as T cast targets an interface with a declared field?: number, three levels deep inside an array, rejects the parse when the JSON genuinely contains an explicit null for that field (rather than omitting it) — and regardless of whether that rejection is intended, the reported error names a completely unrelated location and type: an unrelated array field several levels away, not the actual offending field.
native check passes. Only native dev/native build fail (the compiled corewire validator, not the subset checker).
I want to flag one thing I'm not certain about before assuming this is simply "reject null, full stop": ts-core's own skill doc states the SDK's convention for optional data is T | null (NS1012: "Optional data is T | null"), which is what I'd expect to make a bare JSON null a legitimate optional-value representation, not an invalid one. So either (a) that convention doesn't extend to plain interfaces used only inside a service function body (never crossing the boundary as a service Request/Result), and the rejection is intended — in which case the message should say so accurately, or (b) it's a genuine miscompile of field?: number. I don't have visibility into the corewire internals to tell which. The message inaccuracy holds either way.
Reproduced against native 0.9.5 (npm), macOS 26.6, Zig 0.16.0, from a clean native init --template ts-core.
Repro
// src/services/repro.ts
import type { ParseRequest, ParseResult } from "../shared.ts";
interface RunEntry {
slug?: string;
costUsd?: number;
}
interface RepoEntry {
name?: string;
active?: RunEntry[];
}
interface StatusFile {
repos?: RepoEntry[];
}
// costUsd is declared `number | undefined`, but this fixture supplies an
// explicit JSON `null` for it — a very ordinary shape for anything that
// serializes a nullable DB/JS value straight to JSON (JSON has no
// `undefined`, only `null` or omission).
const FIXTURE = `{"repos":[{"name":"Relay","active":[{"slug":"x","costUsd":null}]}]}`;
export function parseIt(_request: ParseRequest): ParseResult {
let parsed: StatusFile;
try {
parsed = JSON.parse(FIXTURE) as StatusFile;
} catch (e) {
throw { kind: "bad_json", message: `not JSON: ${(e as Error).message}` };
}
const repos = Array.isArray(parsed.repos) ? parsed.repos : [];
return { count: repos.length };
}
// src/shared.ts
export interface ParseRequest { readonly json: Uint8Array; }
export interface ParseResult { readonly count: number; }
// src/core.ts
import { Cmd, Sub } from "@native-sdk/core";
import { reproParseIt } from "@native-sdk/services";
import type { ParseResult } from "./shared.ts";
export interface Model {
readonly count: number;
readonly errText: Uint8Array;
}
export type Msg =
| { readonly kind: "parsed"; readonly result: ParseResult }
| { readonly kind: "parse_failed"; readonly errText: Uint8Array };
export const viewUnbound = ["parsed", "parse_failed"] as const;
export function initialModel(): [Model, Cmd<Msg>] {
const model: Model = { count: -1, errText: new Uint8Array(0) };
return [
model,
reproParseIt({ json: new Uint8Array(0) }, { key: "parse", ok: "parsed", err: "parse_failed" }),
];
}
export function total(model: Model): number { return model.count; }
export function hasError(model: Model): boolean { return model.errText.length > 0; }
export function errorText(model: Model): Uint8Array { return model.errText; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "parsed": return { ...model, count: msg.result.count };
case "parse_failed": return { ...model, errText: msg.errText };
}
}
export function subscriptions(_model: Model): Sub<Msg> { return Sub.none; }
<!-- src/app.native -->
<column gap="12" padding="16">
<text>total: {total}</text>
<if test="{hasError}"><status-bar>{errorText}</status-bar></if>
</column>
Actual
{"kind":"bad_json","message":"status output was not JSON: expected array | undefined at $.repos, got array"}
total stays -1 (the parse_failed arm fires). $.repos genuinely is an array — the message names the wrong field and the wrong type entirely. The real defect is active[0].costUsd, three levels deeper, where the JSON has null against a declared number | undefined.
Expected
Either the cast succeeds (an explicit JSON null against a T | undefined field is an extremely common, valid shape — anything that round-trips a nullable value through JSON has no way to produce undefined, only null or omission), or the checker rejects it with an accurate path/type pointing at active[0].costUsd, not an unrelated array field.
Bisection
Isolated by narrowing an app that hit this against real produced data (a status-aggregator JSON blob with several optional fields, including one number | undefined field genuinely null from a "not yet known" value):
- The full real payload (10+ fields per entry, several excess/undeclared and several
null) → fails with this exact message. - Trimmed to only the fields declared in the TS interfaces, still including
costUsd: null→ still fails. - Removing
costUsd: null(all other fields present, all declared) → builds and runs clean. - A single undeclared extra string property (excess key not in the interface at all) → builds and runs clean — so this is not the excess-property class of bug, specifically an explicit
nullagainst a declared optionalnumber. - Re-adding only
costUsd: nullon the minimal fixture (this repro) → fails, confirming it in isolation.
Impact
Any service function that parses external JSON (a subprocess's stdout, a file, an HTTP response) into a type with an optional numeric field reachable through an array cannot rely on JSON.parse(...) as T when a real producer ever writes null for a "value not yet known" field — a routine pattern (e.g. costUsd: null before a cost is computed). The thrown error is also actively misleading for debugging: it names an unrelated array field, not the actual offending value.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the minimal reproduction from src/services/repro.ts with native dev or native build, using the clean native 0.9.5 setup described; native check is not sufficient. Start by tracing the compiled corewire validator handling of JSON.parse(...) as StatusFile, especially the nested repos/active/costUsd value. Done means either explicit null is accepted consistently with the optional-value convention or the failure identifies $.repos[0].active[0].costUsd and its actual type mismatch.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript, zig
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100