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
null against a declared optional number.
- Re-adding only
costUsd: null on 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.
Summary
A service function whose
JSON.parse(...) as Tcast targets an interface with a declaredfield?: number, three levels deep inside an array, rejects the parse when the JSON genuinely contains an explicitnullfor 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 checkpasses. Onlynative dev/native buildfail (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 isT | null(NS1012: "Optional data isT | null"), which is what I'd expect to make a bare JSONnulla 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 offield?: 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
Actual
totalstays-1(theparse_failedarm fires).$.reposgenuinely is an array — the message names the wrong field and the wrong type entirely. The real defect isactive[0].costUsd, three levels deeper, where the JSON hasnullagainst a declarednumber | undefined.Expected
Either the cast succeeds (an explicit JSON
nullagainst aT | undefinedfield is an extremely common, valid shape — anything that round-trips a nullable value through JSON has no way to produceundefined, onlynullor omission), or the checker rejects it with an accurate path/type pointing atactive[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 | undefinedfield genuinelynullfrom a "not yet known" value):null) → fails with this exact message.costUsd: null→ still fails.costUsd: null(all other fields present, all declared) → builds and runs clean.nullagainst a declared optionalnumber.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 Twhen a real producer ever writesnullfor a "value not yet known" field — a routine pattern (e.g.costUsd: nullbefore a cost is computed). The thrown error is also actively misleading for debugging: it names an unrelated array field, not the actual offending value.