#407

JSON.parse `as` cast on a declared optional number misreports the failing path/type (explicit JSON null, 3 levels deep)

Openarnaudmanarancheopened 2mo ago

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.

Activity

0

No activity yet.