mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-03 07:54:40 +08:00
Keep records cast from unknown[] elements to dictionary types as live views
This commit is contained in:
@@ -4,7 +4,7 @@ Empty record layouts are also snapshots because they have no property storage.
|
||||
`scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal.
|
||||
|
||||
Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage.
|
||||
A record or class instance converted to a string-keyed dictionary type such as `Record<string, string>`, directly or through a `{}`, `object` or `unknown` binding or parameter, keeps its identity when the program writes through, compares or inspects values of that dictionary type.
|
||||
A record or class instance converted to a string-keyed dictionary type such as `Record<string, string>`, directly, through a `{}`, `object` or `unknown` binding or parameter, or as an element of a constant `unknown[]` array that holds such records, keeps its identity when the program writes through, compares or inspects values of that dictionary type.
|
||||
Every dictionary type with the same value type then uses checked dynamic storage, which is slower than a native dictionary.
|
||||
Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js.
|
||||
These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Casts from values whose type is only known at runtime validate the value.
|
||||
For example, `JSON.parse(s) as Config` throws a catchable error if the data does not match `Config`, identifying the offending path, such as `expected number at $.port, got string`.
|
||||
A TypeScript assertion alone does not perform this validation when run under Node.js.
|
||||
A record or class instance cast from `unknown` to a dictionary type such as `Record<string, number>` keeps its identity, and the cast does not validate it.
|
||||
Reading a value that does not match the dictionary's value type throws a `TypeError`, such as `expected number at $, got string`, where Node.js returns the value.
|
||||
|
||||
Casts between program classes are checked too: casting an instance to a class it does not extend throws a `TypeError`, where Node.js reads the members structurally.
|
||||
`SC6004` reports calls that pass such an instance to a function that casts it.
|
||||
|
||||
@@ -973,15 +973,53 @@ export class ClassProtocols {
|
||||
callsNamed: (name: string) => readonly (ts.CallExpression | ts.NewExpression)[],
|
||||
): boolean {
|
||||
if (this.isProgramClassInstance(type) || this.isRecordWithFields(type)) return true;
|
||||
if (!isDynamicView(this.checker, type) || !ts.isIdentifier(source)) return false;
|
||||
if (!isDynamicView(this.checker, type)) return false;
|
||||
const holdsObjectType = (held: ts.Type | undefined): boolean =>
|
||||
held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held));
|
||||
const holdsObject = (expr: ts.Expression): boolean =>
|
||||
holdsObjectType(attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr))));
|
||||
const constInitializer = (name: ts.Identifier): ts.Expression | undefined => {
|
||||
const decl = attempt(() => this.checker.getSymbolAtLocation(name))?.valueDeclaration;
|
||||
return decl !== undefined &&
|
||||
ts.isVariableDeclaration(decl) &&
|
||||
ts.isVariableDeclarationList(decl.parent) &&
|
||||
(decl.parent.flags & ts.NodeFlags.Const) !== 0
|
||||
? decl.initializer
|
||||
: undefined;
|
||||
};
|
||||
if (ts.isElementAccessExpression(source)) {
|
||||
// An element of a constant `unknown[]` binding holds what the
|
||||
// binding's initializer stores: the elements of an array of records
|
||||
// or class instances, or what a `map` callback returns.
|
||||
let base = source.expression;
|
||||
while (ts.isParenthesizedExpression(base)) base = base.expression;
|
||||
const initializer = ts.isIdentifier(base) ? constInitializer(base) : undefined;
|
||||
if (initializer === undefined) return false;
|
||||
const array = attempt(() => this.checker.getTypeAtLocation(initializer));
|
||||
if (
|
||||
array !== undefined &&
|
||||
this.checker.isArrayType(array) &&
|
||||
holdsObjectType(this.checker.getTypeArguments(array as ts.TypeReference)[0])
|
||||
)
|
||||
return true;
|
||||
let call = initializer;
|
||||
while (ts.isParenthesizedExpression(call)) call = call.expression;
|
||||
if (
|
||||
!ts.isCallExpression(call) ||
|
||||
!ts.isPropertyAccessExpression(call.expression) ||
|
||||
call.expression.name.text !== "map"
|
||||
)
|
||||
return false;
|
||||
const callback = call.arguments.nodes[0];
|
||||
return (
|
||||
callback !== undefined &&
|
||||
(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) &&
|
||||
functionResults(callback).some(holdsObject)
|
||||
);
|
||||
}
|
||||
if (!ts.isIdentifier(source)) return false;
|
||||
const decl = attempt(() => this.checker.getSymbolAtLocation(source))?.valueDeclaration;
|
||||
if (decl === undefined) return false;
|
||||
const holdsObject = (expr: ts.Expression): boolean => {
|
||||
const held = attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr)));
|
||||
return (
|
||||
held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held))
|
||||
);
|
||||
};
|
||||
if (ts.isParameter(decl)) {
|
||||
// A parameter holds what its callers pass: the calls of the function
|
||||
// by name whose signature resolves to it.
|
||||
@@ -1000,13 +1038,8 @@ export class ClassProtocols {
|
||||
);
|
||||
});
|
||||
}
|
||||
return (
|
||||
ts.isVariableDeclaration(decl) &&
|
||||
decl.initializer !== undefined &&
|
||||
ts.isVariableDeclarationList(decl.parent) &&
|
||||
(decl.parent.flags & ts.NodeFlags.Const) !== 0 &&
|
||||
holdsObject(decl.initializer)
|
||||
);
|
||||
const initializer = constInitializer(source);
|
||||
return initializer !== undefined && holdsObject(initializer);
|
||||
}
|
||||
|
||||
/** A program object type with declared properties that is not a class
|
||||
@@ -1371,6 +1404,20 @@ const IDENTITY_METHODS = new Set([
|
||||
"includes",
|
||||
]);
|
||||
|
||||
/** The values a function expression answers: an arrow's expression body,
|
||||
* or the operands of its own return statements. */
|
||||
function functionResults(fn: ts.ArrowFunction | ts.FunctionExpression): ts.Expression[] {
|
||||
if (!ts.isBlock(fn.body)) return [fn.body];
|
||||
const results: ts.Expression[] = [];
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return;
|
||||
if (ts.isReturnStatement(node) && node.expression !== undefined) results.push(node.expression);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(fn.body, visit);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** An expression that denotes an object that already exists, not a fresh
|
||||
* literal or call result. */
|
||||
function isExistingObject(node: ts.Expression): boolean {
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/record-unknown-dictionary-views.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Records stored as `unknown` array elements and cast to a dictionary type
|
||||
// keep their identity: writes through the view reach the record, and reads
|
||||
// of fields of the dictionary's value type answer the record's values. The
|
||||
// elements come from map(), an array literal and an array of records.
|
||||
type Stats = { hits: number; misses: number; name: string };
|
||||
const stats: Stats[] = [
|
||||
{ hits: 1, misses: 0, name: "a" },
|
||||
{ hits: 5, misses: 2, name: "b" },
|
||||
];
|
||||
|
||||
const mapped: unknown[] = stats.map((s) => s as unknown);
|
||||
const first = mapped[0] as Record<string, number>;
|
||||
first["hits"] = first["hits"]! + 1;
|
||||
console.log(stats[0]!.hits, (first as unknown) === stats[0], first["misses"]);
|
||||
|
||||
const listed: unknown[] = [stats[1]];
|
||||
const second = listed[0] as Record<string, number>;
|
||||
second["misses"] = second["misses"]! * 10;
|
||||
console.log(stats[1]!.misses, second["hits"]);
|
||||
|
||||
const copied: unknown[] = stats;
|
||||
const again = copied[1] as Record<string, number>;
|
||||
again["hits"] = 50;
|
||||
console.log(stats[1]!.hits, again === second);
|
||||
|
||||
const returned: unknown[] = stats.map((s) => {
|
||||
return s as unknown;
|
||||
});
|
||||
const third = returned[0] as Record<string, number>;
|
||||
third["misses"] = 7;
|
||||
console.log(stats[0]!.misses, third === first);
|
||||
|
||||
function bump(view: Record<string, number>, key: "hits" | "misses"): void {
|
||||
view[key] = view[key]! + 100;
|
||||
}
|
||||
bump(mapped[1] as Record<string, number>, "hits");
|
||||
console.log(stats[1]!.hits, JSON.stringify(stats));
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4500, "san": 53000 }
|
||||
@@ -209,6 +209,40 @@ console.log("unreachable", c.host);
|
||||
expect(r.stderr).toContain("Uncaught TypeError: expected number at $.port, got string");
|
||||
});
|
||||
|
||||
test("a record cast from unknown to a dictionary throws at its mismatched read", async () => {
|
||||
// An element of an `unknown[]` keeps the record's identity, as an
|
||||
// `unknown` binding does: the write lands, and only the read of a
|
||||
// field the dictionary's value type does not admit throws.
|
||||
const r = await compileAndRun(
|
||||
"dictionary-view-mismatch",
|
||||
`type Stats = { hits: number; name: string };
|
||||
const stats: Stats[] = [{ hits: 1, name: "a" }, { hits: 1, name: "b" }];
|
||||
function attempt(label: string, view: Record<string, number>): void {
|
||||
view["hits"] = view["hits"]! + 1;
|
||||
try {
|
||||
console.log(label, view["name"]);
|
||||
} catch (error) {
|
||||
console.log(label, (error as Error).name, (error as Error).message);
|
||||
}
|
||||
}
|
||||
const mapped: unknown[] = stats.map((s) => s as unknown);
|
||||
attempt("mapped", mapped[0] as Record<string, number>);
|
||||
const listed: unknown[] = [stats[1]];
|
||||
attempt("listed", listed[0] as Record<string, number>);
|
||||
console.log(stats[0]!.hits, stats[1]!.hits);
|
||||
`,
|
||||
);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toBe(
|
||||
[
|
||||
"mapped TypeError expected number at $, got string",
|
||||
"listed TypeError expected number at $, got string",
|
||||
"2 2",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("schema-directed JSON.parse declines to the checked route with identical errors", async () => {
|
||||
// `JSON.parse(text) as T` for plain record/array targets parses in one
|
||||
// pass and reruns the checked-dynamic route whenever it declines, so
|
||||
|
||||
Reference in New Issue
Block a user