mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-27 06:43:19 +08:00
Treat optional members an index signature covers as keys, erase 'as never', and keep fromEntries objects asserted to records
A record type whose declared members are all optional and typed as its
index signature's slot (ProcessEnv's TZ?, '{ CI?: string } &
Dictionary<string>') interns as the pure index-signature record, its
slot admitting undefined: spread, delete and enumeration then follow one
insertion order. 'value as never' erases, so the consuming slot checks
an unknown value like any other. 'Object.fromEntries(...) as T' for a
declared record T goes through the checked-dynamic bridge, which keeps
the entries' keys and order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4bbf31b61f
commit
85a2567ecd
@@ -1151,6 +1151,50 @@ export function withRuntimeOptionalNarrowed<T>(
|
||||
* memory);
|
||||
* - dyn → anything else (closures, class instances, void) is rejected:
|
||||
* those types cannot be found inside a JSON dyn. */
|
||||
/** `Object.fromEntries(...) as T` with T a declared record (the generic
|
||||
* `transform<T extends Record<string, unknown>>(t: T): T` idiom): the
|
||||
* entries build a pure index-signature record, which has no layout in
|
||||
* common with T's struct. Node keeps the object itself, with exactly the
|
||||
* keys and the order the entries gave it. The result of fromEntries is a
|
||||
* fresh object nothing else references, so the checked-dynamic bridge,
|
||||
* which keeps keys and their order the same way `JSON.parse(text) as T`
|
||||
* does, cannot be told apart from the original object; a value outside
|
||||
* T throws the catchable TypeError of every checked cast. Other sources
|
||||
* may be aliased, where a rebuilt object would sever mutation, so they
|
||||
* keep the exact-shape fence. */
|
||||
function freshEntriesRecordAssertion(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.AsExpression | ts.TypeAssertion,
|
||||
inner: IrExpr,
|
||||
target: IrType | null,
|
||||
): IrExpr | null {
|
||||
if (inner.type.kind !== "record" || target?.kind !== "record") return null;
|
||||
const source = lowerer.shapes.get(inner.type.shapeId);
|
||||
const dest = lowerer.shapes.get(target.shapeId);
|
||||
if (!source?.indexValue || source.tuple || source.fields.length > 0) return null;
|
||||
if (!dest || dest.tuple || dest.indexValue || shapeHasAccessorSlots(dest)) return null;
|
||||
let operand: ts.Expression = expr.expression;
|
||||
while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
|
||||
if (
|
||||
!ts.isCallExpression(operand) ||
|
||||
!ts.isPropertyAccessExpression(operand.expression) ||
|
||||
operand.expression.name.text !== "fromEntries" ||
|
||||
!lowerer.isStdlibGlobal(operand.expression.expression, "Object")
|
||||
)
|
||||
return null;
|
||||
if (!lowerer.dynConvertible(inner.type)) return null;
|
||||
if (
|
||||
!canDynCheckTo(
|
||||
target,
|
||||
(id) => lowerer.shapes.get(id),
|
||||
(id) => lowerer.unions.get(id),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const loc = locOf(expr);
|
||||
return { kind: "dynCheck", value: lowerer.coerceToExpected(inner, DYN), type: target, loc };
|
||||
}
|
||||
|
||||
/** `e as T` and the old-style assertion `<T>e` — one node shape (both
|
||||
* carry `.type` and `.expression`), one lowering. */
|
||||
export function lowerAsExpression(
|
||||
@@ -1286,6 +1330,8 @@ export function lowerAsExpression(
|
||||
) {
|
||||
return lowerer.coerceInto(expr, inner, target);
|
||||
}
|
||||
const rebuilt = freshEntriesRecordAssertion(lowerer, expr, inner, target);
|
||||
if (rebuilt) return rebuilt;
|
||||
// Static assertions normally erase, but record layouts are
|
||||
// monomorphic: a consumer selected from the asserted shape must see
|
||||
// that shape physically. Reuse the ordinary slot coercion so plain
|
||||
@@ -1327,6 +1373,12 @@ export function lowerAsExpression(
|
||||
return inner; // erasure, unchanged
|
||||
}
|
||||
|
||||
// `value as never` is the escape hatch that makes an unknown value fit
|
||||
// any slot (`tagSuccess(value as never)`). No value has type never, so
|
||||
// checking against its placeholder layout would reject every value.
|
||||
// Erase it instead: the consuming slot applies the same validated
|
||||
// extraction an unknown value gets wherever the checker admits it.
|
||||
if (expr.type.kind === ts.SyntaxKind.NeverKeyword) return inner;
|
||||
const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type);
|
||||
const target = lowerer.mapTypeOf(targetTs) ?? dynFallbackType(lowerer, expr.type, targetTs);
|
||||
if (!target) lowerer.badType(expr.type, targetTs);
|
||||
|
||||
@@ -5860,6 +5860,8 @@ function mapRecordType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
(inner.indexValue === undefined || inner.indexValue.kind === "dyn")
|
||||
)
|
||||
return DYN;
|
||||
const covered = coveredOptionalFieldsShape(widened, inner, ctx);
|
||||
if (covered !== null) return { kind: "record", shapeId: covered };
|
||||
const refined = refinedConstituentShape(widened, inner, ctx);
|
||||
if (refined !== null) return { kind: "record", shapeId: refined };
|
||||
return {
|
||||
@@ -5871,6 +5873,51 @@ function mapRecordType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** An index signature already describes every OPTIONAL declared member
|
||||
* whose type is its slot type: @types/node's `ProcessEnv extends
|
||||
* Dict<string> { TZ?: string }` and every spread of it, or a declared
|
||||
* environment `{ CI?: string } & Dictionary<string>`. At runtime such a
|
||||
* member is just another key, so the shape interns as the pure
|
||||
* index-signature record. Its slot admits undefined, as the optional
|
||||
* members do (`env.CI = undefined` keeps the key). A hybrid layout would
|
||||
* give each member a struct slot, which enumerates before the overflow
|
||||
* keys (Node keeps one insertion order) and makes `delete` and spread
|
||||
* answer differently for declared and undeclared keys. Required members,
|
||||
* and members typed otherwise than the slot (whose reads the slot type
|
||||
* could not give back), keep the hybrid shape. */
|
||||
function coveredOptionalFieldsShape(
|
||||
widened: ts.Type,
|
||||
parts: RecordShapeParts,
|
||||
ctx: TypeMapperCtx,
|
||||
): string | null {
|
||||
const slot = parts.indexValue;
|
||||
if (!slot || parts.fields.length === 0) return null;
|
||||
const { checker, shapes, unions } = ctx;
|
||||
// The members' common type must be the slot plus undefined. Read it off
|
||||
// the members (already interned) rather than building it: a hybrid that
|
||||
// does not qualify must not intern anything new.
|
||||
const optionalSlot = parts.fields[0]!.type;
|
||||
if (!parts.fields.every((field) => typeEquals(field.type, optionalSlot))) return null;
|
||||
if (optionalSlot.kind !== "union" || unions.armTag(optionalSlot.unionId, UNDEFINED_T) < 0)
|
||||
return null;
|
||||
const memberArms = unions.get(optionalSlot.unionId)?.arms ?? [];
|
||||
const slotArms =
|
||||
slot.kind === "union" ? (unions.get(slot.unionId)?.arms ?? []) : isUnitType(slot) ? [] : [slot];
|
||||
const definedArms = memberArms.filter((arm) => arm.kind !== "undefinedT");
|
||||
const slotDefined = slotArms.filter((arm) => arm.kind !== "undefinedT");
|
||||
if (
|
||||
definedArms.length === 0 ||
|
||||
definedArms.length !== slotDefined.length ||
|
||||
!slotDefined.every((arm) => definedArms.some((other) => typeEquals(arm, other)))
|
||||
)
|
||||
return null;
|
||||
if (!isSupportedIndexValue(optionalSlot)) return null;
|
||||
const props = checker.getPropertiesOfType(widened);
|
||||
if (props.length !== parts.fields.length) return null;
|
||||
if (!props.every((p) => (p.flags & ts.SymbolFlags.Optional) !== 0)) return null;
|
||||
return shapes.intern([], false, optionalSlot, []);
|
||||
}
|
||||
|
||||
/** A refinement intersection (`Expr & { kind: "mul" }`, which the checker
|
||||
* reduces to `Mul & { kind: "mul" }`) keeps the shape of the constituent it
|
||||
* refines when the refinement changes no field's representation. Recursive
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6465-optional-members-over-index-signature.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6466-never-assertion-argument.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6467-fromentries-record-assertion.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// An index signature whose value admits undefined already describes an
|
||||
// optional member of the same type (`interface Env extends Dict<string>
|
||||
// { TZ?: string }`, @types/node's ProcessEnv): such a record is one key
|
||||
// set in insertion order, so spread, delete, `in` and every enumeration
|
||||
// treat the declared member like any other key.
|
||||
interface Env {
|
||||
TZ?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
const base: Env = { A: "1", TZ: "UTC", B: "2" };
|
||||
const env = { ...base };
|
||||
env.C = "3";
|
||||
for (const key of ["A", "TZ", "MISSING"]) {
|
||||
delete env[key];
|
||||
}
|
||||
console.log(Object.entries(env), Object.keys(base));
|
||||
const probe = (key: string): boolean => key in env;
|
||||
console.log(probe("TZ"), probe("B"), env.TZ, env.B);
|
||||
env.TZ = "Europe/Berlin";
|
||||
console.log(JSON.stringify(env), env);
|
||||
|
||||
const extended: Env = { ...base, D: "4" };
|
||||
delete extended.TZ;
|
||||
extended.TZ = "Asia/Tokyo";
|
||||
console.log(extended, Object.keys(extended).join(","));
|
||||
|
||||
// A declared member written after another key comes after it.
|
||||
interface Scores {
|
||||
best?: number;
|
||||
[name: string]: number | undefined;
|
||||
}
|
||||
const scores: Scores = {};
|
||||
scores["x"] = 1;
|
||||
scores.best = 2;
|
||||
console.log(Object.keys(scores), JSON.stringify(scores));
|
||||
|
||||
// The process environment has the same shape; a child environment built
|
||||
// from it keeps the parent's keys in order.
|
||||
process.env.SCX_FIRST = "first";
|
||||
process.env.SCX_DROP = "drop";
|
||||
process.env.SCX_LAST = "last";
|
||||
const DROP = ["SCX_DROP", "SCX_NOT_SET"];
|
||||
function childEnv(): string[] {
|
||||
const runEnv = { ...process.env };
|
||||
runEnv.SCX_ADDED = "added";
|
||||
for (const key of DROP) {
|
||||
delete runEnv[key];
|
||||
}
|
||||
runEnv.SCX_FIRST = runEnv.SCX_FIRST ? `${runEnv.SCX_FIRST}!` : "none";
|
||||
return Object.entries(runEnv)
|
||||
.filter(([key]) => key.startsWith("SCX_"))
|
||||
.map(([key, value]) => `${key}=${value || ""}`);
|
||||
}
|
||||
console.log(childEnv());
|
||||
|
||||
// A record of declared optional members over a slot without undefined is
|
||||
// the same key set: merging it over the process environment keeps both.
|
||||
type Dictionary<T> = { [key: string]: T };
|
||||
type BuildEnv = { CI?: string; DEBUG?: string } & Dictionary<string>;
|
||||
function merged(buildEnv: BuildEnv): string[] {
|
||||
const env = { ...process.env, ...buildEnv };
|
||||
return [
|
||||
String(env.CI === "1" || env.SCX_FIRST === "first"),
|
||||
`${env.DEBUG ?? "-"}`,
|
||||
Object.keys(env)
|
||||
.filter((key) => key.startsWith("SCX_") || key === "CI" || key === "DEBUG")
|
||||
.join(","),
|
||||
];
|
||||
}
|
||||
const buildEnv: BuildEnv = { SCX_BUILD: "b", CI: "1" };
|
||||
buildEnv.DEBUG = "yes";
|
||||
console.log(merged(buildEnv), Object.keys(buildEnv), buildEnv.CI, buildEnv["SCX_BUILD"]);
|
||||
@@ -0,0 +1,31 @@
|
||||
// `value as never` makes an unknown value fit any parameter: the argument
|
||||
// is checked against the parameter's type like any unknown value the
|
||||
// checker admits there, and a typed value passes unchanged.
|
||||
type Out = { routes: { src: string; dest?: string }[]; count: number };
|
||||
|
||||
function show(tags: Record<string, unknown>): void {
|
||||
console.log(JSON.stringify(tags));
|
||||
}
|
||||
|
||||
function report(tag: (result: Out) => Record<string, unknown>, value: unknown): void {
|
||||
show(tag(value as never));
|
||||
}
|
||||
|
||||
const describe = (r: Out): Record<string, unknown> => ({ count: r.count, first: r.routes[0]?.src });
|
||||
report(describe, JSON.parse('{"routes":[{"src":"/a"},{"src":"/b","dest":"/c"}],"count":2}'));
|
||||
|
||||
// Through a promise of unknown, the way a wrapper tags an async result.
|
||||
function later(value: Out): Promise<unknown> {
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
later({ routes: [], count: 7 }).then((value) => {
|
||||
show(describe(value as never));
|
||||
});
|
||||
|
||||
// A typed value keeps its identity through the assertion.
|
||||
function bump(target: Out): void {
|
||||
target.count++;
|
||||
}
|
||||
const typed: Out = { routes: [{ src: "/x" }], count: 1 };
|
||||
bump(typed as never);
|
||||
console.log(typed.count);
|
||||
@@ -0,0 +1,29 @@
|
||||
// `Object.fromEntries(...) as T` for a declared record T keeps exactly
|
||||
// the keys the entries created, in their order, as Node keeps the object
|
||||
// itself.
|
||||
type Tags = { "error.type": string; "error.msg": string; "error.cause"?: string };
|
||||
|
||||
function scrub(input: string): string {
|
||||
return input.replace(/secret/g, "***");
|
||||
}
|
||||
|
||||
function redact(tags: Tags): Tags {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tags).map(([key, value]) => [key, typeof value === "string" ? scrub(value) : value]),
|
||||
) as Tags;
|
||||
}
|
||||
|
||||
const e = new Error("a secret here");
|
||||
const plain = redact({ "error.type": e.name, "error.msg": e.message });
|
||||
console.log(plain, Object.keys(plain), "error.cause" in plain, plain["error.msg"].length);
|
||||
const caused = redact({ "error.type": "TypeError", "error.msg": "m", "error.cause": "secret cause" });
|
||||
console.log(caused, JSON.stringify(caused));
|
||||
|
||||
// Keys the record does not declare stay with the object.
|
||||
type Point = { x: number; y: number };
|
||||
const p = Object.fromEntries([
|
||||
["y", 2],
|
||||
["x", 1],
|
||||
["z", 3],
|
||||
]) as Point;
|
||||
console.log(p, p.x + p.y, Object.keys(p), JSON.stringify(p));
|
||||
Reference in New Issue
Block a user