Map JavaScript [] literal fields dynamically and keep union-added fields unit-only

Two JavaScript object literal mappings from #1799 broke packages.

A property initialized to `[]` has the checker type `never[]`, and never's
element maps to f64. A literal with such a property builds as a
checked-dynamic object when it lowers on its own. A literal nested in one
that maps to a record (`{ common: { issues: [] }, path: params?.path || [] }`,
zod 3's parse context) lowers against its record type, so the property was a
`number[]`. `ctx.common.issues.push(issue)` then threw "TypeError: expected
number at $, got object", where Node pushes the record. Such a property now
maps to the checked-dynamic array, as a property initialized to `undefined`
or `null` maps to the checked-dynamic value.

That `undefined`/`null` rule also matched the properties the checker adds
when it normalizes a union of literals: `{ _tag, value } | { _tag, error }`
gains an optional `error?: undefined` and `value?: undefined`. Mapping those
to the checked-dynamic value let `{ _tag, value }` fit both arms, and Effect
4's CLI parser (`consumeFlagValue`) failed with SC2003 "union conversion has
no unambiguous layout mapping". The rule now skips optional properties, so
they keep their unit-only type and each literal converts to its own arm.
This commit is contained in:
Chris Tate
2026-10-22 18:24:10 -05:00
parent e0c70c0c01
commit de28aeca20
7 changed files with 74 additions and 0 deletions
@@ -5893,6 +5893,18 @@ function emptyNeverArrayProp(p: ts.Symbol, fieldTs: ts.Type, checker: ts.TypeChe
);
}
/** A JavaScript object literal's property typed `never[]` (`{ issues: [] }`).
* Nothing checks a JavaScript file's later writes, which can push any value
* onto it (`ctx.common.issues.push(issue)`), so it holds the checked-dynamic
* array rather than never's f64 element array. */
function jsNeverArrayProp(p: ts.Symbol, fieldTs: ts.Type, checker: ts.TypeChecker): boolean {
return (
jsObjectLiteralProperty(p) &&
checker.isArrayType(fieldTs) &&
(checker.getTypeArguments(fieldTs as ts.TypeReference)[0]!.flags & ts.TypeFlags.Never) !== 0
);
}
/** A JavaScript object literal's property (`{ lastComponent: undefined }`,
* `{ user: null }`). Nothing checks a JavaScript file's later writes, which
* can store any value in it (`path.lastComponent = { ... }`), so a
@@ -5904,6 +5916,12 @@ function isUnitOnlyIr(t: IrType, ctx: TypeMapperCtx): boolean {
return def !== undefined && def.arms.every((arm) => isUnitType(arm));
}
/** A property a JavaScript object literal declares. The checker also adds
* each missing property, optional and `undefined`, to every literal of a
* union of literals (`{ _tag, value } | { _tag, error }` gains
* `error?: undefined` and `value?: undefined`); those keep their unit-only
* type, so a literal still converts to the one arm whose properties it
* has. */
function jsObjectLiteralProperty(p: ts.Symbol): boolean {
const d = p.valueDeclaration;
// `__proto__: null` sets the literal's prototype; it declares no property.
@@ -5911,6 +5929,7 @@ function jsObjectLiteralProperty(p: ts.Symbol): boolean {
d !== undefined &&
(ts.isPropertyAssignment(d) || ts.isShorthandPropertyAssignment(d)) &&
ts.symbolName(p) !== "__proto__" &&
(p.flags & ts.SymbolFlags.Optional) === 0 &&
isJsSourceFile(ts.getSourceFileOfNode(d))
);
}
@@ -7983,6 +8002,7 @@ function mapRecordTypeInner(
if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam))
pt = unitOnlyUnion(ctx.unions);
if (pt !== null && isUnitOnlyIr(pt, ctx) && jsObjectLiteralProperty(p)) pt = DYN;
if (pt?.kind === "array" && jsNeverArrayProp(p, fieldTs, checker)) pt = DYN;
// dyn FIELDS map now (`{ v: unknown }`, `[string, unknown]` tuples):
// the slot carries a dyn value exactly like an `unknown`-valued
// overflow entry — same RC adapters, same dynFrom conversion on the
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7014-js-never-array-literal-fields/main.ts"
],
"diags": []
}
@@ -0,0 +1,10 @@
// A JavaScript package whose object literals start an array property as
// `[]` inside a literal that maps to a record, as zod 3's parse context does
// (`{ common: { issues: [] }, path: params?.path || [] }`), and later push
// records onto it. Node runs the pushes; each array holds them.
import { Schema, rows } from "mini";
const s = new Schema();
console.log(JSON.stringify(s.safeParse(1)), JSON.stringify(s.safeParse("a")));
console.log(JSON.stringify(s.safeParse(2, { path: ["root"] })));
console.log(rows());
@@ -0,0 +1,5 @@
export interface Issue { code: string; path: (string | number)[]; message: string }
export declare class Schema {
safeParse(data: unknown, params?: { async?: boolean; path?: (string | number)[] }): { success: true; data: string } | { success: false; issues: Issue[] };
}
export declare function rows(): string;
@@ -0,0 +1,31 @@
// A parse context as zod 3 keeps it: the issue list starts as `[]` in a
// nested literal, and the path is the caller's array or `[]`.
export function makeIssue(params) {
const { path, issueData } = params;
const fullPath = [...path, ...(issueData.path || [])];
return { ...issueData, path: fullPath, message: "m" };
}
export function addIssue(ctx, issueData) {
const issue = makeIssue({ issueData, path: ctx.path });
ctx.common.issues.push(issue);
}
export class Schema {
safeParse(data, params) {
const ctx = {
common: { issues: [], async: params?.async ?? false },
path: params?.path || [],
data,
};
if (typeof data !== "string") addIssue(ctx, { code: "invalid_type" });
return ctx.common.issues.length === 0 ? { success: true, data } : { success: false, issues: ctx.common.issues };
}
}
// Array properties of records in an array literal and in a deeper literal,
// filled after construction.
export function rows() {
const table = { rows: [{ cells: [] }, { cells: [] }], meta: { tags: { list: [] } }, size: 2 };
table.rows[0].cells.push({ v: 1 });
table.rows[1].cells.push("text", 3);
table.meta.tags.list.push({ name: "t" }, null);
return JSON.stringify(table);
}
@@ -0,0 +1 @@
{ "name": "mini", "version": "1.0.0", "type": "module", "main": "./index.js", "types": "./index.d.ts" }
@@ -0,0 +1 @@
{ "plain": 3004, "san": 3359 }