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)

Open
#407 0 comments 0 reactions 0 assignees View on GitHub

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 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.