Skip to content

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

Description

@arnaudmanaranche

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions