mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Compile JavaScript classes like TypeScript classes when their layout or JSDoc types allow it (#1232)
This commit is contained in:
@@ -25,7 +25,7 @@ LLVM helper -------------> assembly / object (--emit=asm|obj)
|
||||
Runtime pack and linker -> executable
|
||||
```
|
||||
|
||||
1. **Frontend:** TypeScript parses and checks the program against `es2025`, scriptc's ambient declarations, and available project declarations. `tsconfig.json` controls checker strictness. The compiler uses the checked types and narrowing information to lower supported operations; unsupported operations produce diagnostics.
|
||||
1. **Frontend:** TypeScript parses and checks the program against `es2025`, scriptc's ambient declarations, and available project declarations. `tsconfig.json` controls checker strictness. The compiler uses the checked types and narrowing information to lower supported operations; unsupported operations produce diagnostics. JavaScript files that TypeScript checks (a `// @ts-check` comment, or `checkJs` in `tsconfig.json`) report values that do not match their JSDoc types as errors, and their classes use those types the way TypeScript classes use annotations.
|
||||
2. **Typed IR:** A validated, serializable intermediate representation connects the frontend and backend. Types are concrete: generic functions are specialized, unions use tags, and closures have explicit captures. `--emit=ir` writes this representation as JSON.
|
||||
3. **LLVM backend:** `--emit=llvm` writes textual LLVM IR. The matching LLVM 22 helper generates assembly or objects on supported macOS, Linux, and Windows hosts, including output targeting WASI. Executable and library builds use this backend. Large executables, native and WASI alike, are divided into partitions that are optimized and compiled in parallel; optimized builds summarize every partition first so small functions can still be inlined across partitions. The partition count depends only on the program, so output is the same on every host, and a rebuild reuses the compiled partitions that an edit does not affect.
|
||||
4. **Linking:** Runtime packs contain the precompiled C runtime and vendored libraries. A hashed manifest selects the artifacts the program needs for its features, optimization mode, and (for libraries) state model. The target linker and SDK or sysroot produce the executable. Sanitizer builds use an instrumented C runtime.
|
||||
|
||||
@@ -123,6 +123,24 @@ export function generatorMeta(
|
||||
return { yieldT: type.yieldT, nextT: type.nextT, resultType };
|
||||
}
|
||||
|
||||
/** A parameter with a type annotation or a JSDoc `@param`/`@type` tag. */
|
||||
export function documentedParameter(param: ts.ParameterDeclaration): boolean {
|
||||
return (
|
||||
param.type !== undefined ||
|
||||
/@(?:param|type)\b/.test(
|
||||
param.getSourceFile().text.slice(param.parent?.pos ?? param.pos, param.getStart()),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** A documented parameter in a JavaScript file TypeScript checks. */
|
||||
export function checkedJsDocumentedParameter(
|
||||
lowerer: Lowerer,
|
||||
param: ts.ParameterDeclaration,
|
||||
): boolean {
|
||||
return lowerer.checkedJsFile(param.getSourceFile()) && documentedParameter(param);
|
||||
}
|
||||
|
||||
/** One parameter's ParamShape — the shared signature-shaped collection
|
||||
* point for function declarations, methods, constructors, and lambdas
|
||||
* (generic declarations defer to their call sites, where the resolved
|
||||
@@ -147,11 +165,14 @@ export function paramShape(lowerer: Lowerer, param: ts.ParameterDeclaration): Pa
|
||||
if (
|
||||
isJsSourceFile(param.getSourceFile()) &&
|
||||
ts.isConstructorDeclaration(param.parent) &&
|
||||
!param.dotDotDotToken
|
||||
!param.dotDotDotToken &&
|
||||
!checkedJsDocumentedParameter(lowerer, param)
|
||||
) {
|
||||
// Reflective JavaScript construction can supply values outside the
|
||||
// constructor's documented usual types. Retain the actual arguments
|
||||
// and apply checked conversions only at operations that need them.
|
||||
// In a file TypeScript checks, documented types are enforced like
|
||||
// TypeScript annotations.
|
||||
return param.initializer
|
||||
? { type: DYN, mode: "omittable", bodyType: DYN }
|
||||
: { type: DYN, mode: param.questionToken ? "omittable" : "required" };
|
||||
|
||||
@@ -19,7 +19,9 @@ import { checkedIterableSpread } from "./checked-iterable-spread.js";
|
||||
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
|
||||
|
||||
/** Unmodified typed methods retain direct or virtual native dispatch.
|
||||
* JavaScript notification methods keep their checked property fallback. */
|
||||
* JavaScript notification methods keep their checked property fallback,
|
||||
* except in files TypeScript checks, whose methods join the method census
|
||||
* like TypeScript methods. */
|
||||
export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string): boolean {
|
||||
if (!isClassOwnEnumerableFieldName(name) || name.startsWith("get:") || name.startsWith("set:"))
|
||||
return false;
|
||||
@@ -38,6 +40,7 @@ export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string)
|
||||
(lowerer.prototypeMethodAccesses.has(name) ||
|
||||
(info.decl !== null &&
|
||||
isJsSourceFile(info.decl.getSourceFile()) &&
|
||||
!lowerer.checkedJsFile(info.decl.getSourceFile()) &&
|
||||
found.sig.params.length === 0 &&
|
||||
found.sig.ret.kind === "void"))
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ import { ClassConstructionDispatch } from "./class-construction.js";
|
||||
import { emitterRooted } from "./lower-event-emitter.js";
|
||||
import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js";
|
||||
import { STREAM_OPERATORS } from "./lower-stream.js";
|
||||
import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js";
|
||||
|
||||
const STREAM_METHODS = new Set([
|
||||
"on",
|
||||
@@ -671,6 +672,9 @@ export class ClassDynamicDispatch {
|
||||
private readonly generatorDispatch = new GeneratorDynamicDispatch();
|
||||
private readonly caughtDispatch = new CaughtValueDispatch();
|
||||
private readonly constructionDispatch = new ClassConstructionDispatch();
|
||||
/** Property names a delete can remove (null: any name), for the field
|
||||
* creation tracking decision. */
|
||||
private deletedNames: Set<string> | null | undefined;
|
||||
private readonly boxed = new Set<string>();
|
||||
private readonly boxedConstructors = new Set<string>();
|
||||
private readonly dispatches = new Map<string, Dispatch>();
|
||||
@@ -1164,8 +1168,11 @@ export class ClassDynamicDispatch {
|
||||
current.def.tracksOwnFields = true;
|
||||
current.subclasses.forEach(track);
|
||||
};
|
||||
// Runtime-owned roots keep their dedicated property contracts.
|
||||
if (!root.base?.def.runtime) track(root);
|
||||
// Runtime-owned roots keep their dedicated property contracts. A
|
||||
// family whose fields all exist from construction and are never
|
||||
// deleted keeps a fixed layout, as TypeScript classes do.
|
||||
this.deletedNames ??= deletedPropertyNames(lowerer.moduleOrder);
|
||||
if (!root.base?.def.runtime && !fixedFieldFamily(root, this.deletedNames)) track(root);
|
||||
}
|
||||
const loc = this.propertyBag.loc;
|
||||
const { receiver, condition } = this.propertyReceiver(plan, loc);
|
||||
|
||||
@@ -48,9 +48,10 @@ export function collectClassMethodMutations(
|
||||
let unknownOwnField = false;
|
||||
// JavaScript method inference and dynamic class factories keep their
|
||||
// existing dispatch contract. The native-call proof applies to typed
|
||||
// declarations, while writes in either source language can invalidate it.
|
||||
// declarations (including JavaScript files TypeScript checks), while
|
||||
// writes in either source language can invalidate it.
|
||||
for (const file of files) {
|
||||
if (isJsSourceFile(file)) continue;
|
||||
if (isJsSourceFile(file) && !lowerer.checkedJsFile(file)) continue;
|
||||
ts.walkPreorder(file, (node) => {
|
||||
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) typedClasses.push(node);
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import { isClassOwnEnumerableFieldName } from "../../ir/ir.js";
|
||||
import type { ClassInfo } from "./lower-classes.js";
|
||||
|
||||
/** Reflection that can remove a property or change its attributes. */
|
||||
const PROPERTY_REFLECTION = new Set(["deleteProperty", "freeze", "seal", "preventExtensions"]);
|
||||
|
||||
/** `Object.freeze({ ... })` and the like apply to a new literal, which is
|
||||
* not a class instance. */
|
||||
function freshLiteralArgument(callee: ts.PropertyAccessExpression): boolean {
|
||||
const call = callee.parent;
|
||||
if (!ts.isCallExpression(call) || call.expression !== callee) return false;
|
||||
let argument = call.arguments[0];
|
||||
while (argument && ts.isParenthesizedExpression(argument)) argument = argument.expression;
|
||||
return (
|
||||
argument !== undefined &&
|
||||
(ts.isObjectLiteralExpression(argument) || ts.isArrayLiteralExpression(argument))
|
||||
);
|
||||
}
|
||||
|
||||
/** Property names that a `delete` in `files` can remove, or null when a
|
||||
* delete with a computed key or a reflective removal can remove any name. */
|
||||
export function deletedPropertyNames(files: readonly ts.SourceFile[]): Set<string> | null {
|
||||
const names = new Set<string>();
|
||||
let any = false;
|
||||
for (const file of files)
|
||||
ts.walkPreorder(file, (node) => {
|
||||
if (any) return "stop";
|
||||
if (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
PROPERTY_REFLECTION.has(node.name.text) &&
|
||||
!freshLiteralArgument(node)
|
||||
) {
|
||||
any = true;
|
||||
return "stop";
|
||||
}
|
||||
if (!ts.isDeleteExpression(node)) return undefined;
|
||||
let target = node.expression;
|
||||
while (ts.isParenthesizedExpression(target)) target = target.expression;
|
||||
if (ts.isPropertyAccessExpression(target)) names.add(target.name.text);
|
||||
else if (
|
||||
ts.isElementAccessExpression(target) &&
|
||||
(ts.isStringLiteralLike(target.argumentExpression) ||
|
||||
ts.isNumericLiteral(target.argumentExpression))
|
||||
)
|
||||
names.add(target.argumentExpression.text);
|
||||
else any = true;
|
||||
return undefined;
|
||||
});
|
||||
return any ? null : names;
|
||||
}
|
||||
|
||||
/** Whether `node` reads or passes `this` or `super`. */
|
||||
function usesReceiver(node: ts.Node): boolean {
|
||||
let found = false;
|
||||
ts.walkPreorder(node, (part) => {
|
||||
if (part.kind === ts.SyntaxKind.ThisKeyword || part.kind === ts.SyntaxKind.SuperKeyword) {
|
||||
found = true;
|
||||
return "stop";
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The field a statement `this.name = value` creates when `value` does not
|
||||
* use the receiver, or null for any other statement. */
|
||||
function receiverFreeFieldAssignment(statement: ts.Statement): string | null {
|
||||
if (!ts.isExpressionStatement(statement)) return null;
|
||||
const assignment = statement.expression;
|
||||
if (
|
||||
!ts.isBinaryExpression(assignment) ||
|
||||
assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
|
||||
!ts.isPropertyAccessExpression(assignment.left) ||
|
||||
assignment.left.expression.kind !== ts.SyntaxKind.ThisKeyword ||
|
||||
usesReceiver(assignment.right)
|
||||
)
|
||||
return null;
|
||||
return assignment.left.name.text;
|
||||
}
|
||||
|
||||
export interface Construction {
|
||||
/** Fields created before any code could observe the instance. */
|
||||
created: Set<string>;
|
||||
/** No code could observe the instance before construction finished. */
|
||||
sealed: boolean;
|
||||
}
|
||||
|
||||
/** The fields a class's construction creates before the instance can be
|
||||
* observed: field declarations and top-level `this.name = value`
|
||||
* statements whose values do not use the receiver, up to the first
|
||||
* statement that uses `this` or `super` in any other way. A subclass
|
||||
* continues its base's sequence (`base`) only when the whole base
|
||||
* construction kept the instance unobserved. `isField` tells fields from
|
||||
* names whose assignment runs a setter or adds a property. Null when the
|
||||
* constructor's shape is not analyzed. */
|
||||
export function constructionPrefix(
|
||||
decl: ts.ClassDeclaration | ts.ClassExpression,
|
||||
base: Construction | null,
|
||||
isField: (name: string) => boolean,
|
||||
): Construction | null {
|
||||
const created = new Set(base?.created ?? []);
|
||||
let sealed = base?.sealed ?? true;
|
||||
const ctor = decl.members.find(
|
||||
(member): member is ts.ConstructorDeclaration =>
|
||||
ts.isConstructorDeclaration(member) && member.body !== undefined,
|
||||
);
|
||||
if (ctor?.parameters.some((parameter) => usesReceiver(parameter))) sealed = false;
|
||||
const statements = ctor?.body?.statements ?? [];
|
||||
// A derived constructor's statements before super() cannot reach the
|
||||
// instance; the field declarations run when super() returns.
|
||||
let at = 0;
|
||||
if (base && ctor) {
|
||||
const superAt = statements.findIndex(
|
||||
(statement) =>
|
||||
ts.isExpressionStatement(statement) &&
|
||||
ts.isCallExpression(statement.expression) &&
|
||||
statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword,
|
||||
);
|
||||
if (superAt < 0) return null;
|
||||
const call = (statements[superAt] as ts.ExpressionStatement).expression as ts.CallExpression;
|
||||
if (call.arguments.some(usesReceiver)) sealed = false;
|
||||
at = superAt + 1;
|
||||
}
|
||||
for (const member of decl.members) {
|
||||
if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) continue;
|
||||
if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword))
|
||||
continue;
|
||||
if (member.initializer && usesReceiver(member.initializer)) sealed = false;
|
||||
if (sealed) created.add(member.name.text);
|
||||
}
|
||||
for (const statement of statements.slice(at)) {
|
||||
if (!sealed) break;
|
||||
// A name that is not a field runs a setter or adds a property bag
|
||||
// entry, either of which can observe the instance.
|
||||
const name = receiverFreeFieldAssignment(statement);
|
||||
if (name !== null && isField(name)) created.add(name);
|
||||
else if (name !== null || usesReceiver(statement) || ts.isReturnStatement(statement))
|
||||
sealed = false;
|
||||
}
|
||||
return { created, sealed };
|
||||
}
|
||||
|
||||
/** Whether a class declares a getter or setter, which can intercept a
|
||||
* base constructor's field assignment. */
|
||||
function declaresAccessor(info: ClassInfo): boolean {
|
||||
return [...info.methods.keys()].some(
|
||||
(name) => name.startsWith("get:") || name.startsWith("set:"),
|
||||
);
|
||||
}
|
||||
|
||||
/** `constructionPrefix` for a collected class and its bases. */
|
||||
export function construction(info: ClassInfo): Construction | null {
|
||||
if (!info.decl) return null;
|
||||
let base: Construction | null = null;
|
||||
if (info.base) {
|
||||
base = construction(info.base);
|
||||
if (!base) return null;
|
||||
if (declaresAccessor(info)) base = { created: base.created, sealed: false };
|
||||
}
|
||||
return constructionPrefix(info.decl, base, (name) => info.fields.has(name));
|
||||
}
|
||||
|
||||
/** Whether every class in the family under `root` creates all of its
|
||||
* enumerable fields before its instances can be observed, and no field
|
||||
* name can be deleted. Such fields exist for the instance's whole life in
|
||||
* layout order, as TypeScript fields do, so their writes need no creation
|
||||
* tracking. */
|
||||
export function fixedFieldFamily(root: ClassInfo, deleted: Set<string> | null): boolean {
|
||||
if (deleted === null) return false;
|
||||
const fixed = (info: ClassInfo): boolean => {
|
||||
if (declaresAccessor(info)) return false;
|
||||
const built = construction(info);
|
||||
if (!built) return false;
|
||||
for (const field of info.fieldOrder) {
|
||||
if (!isClassOwnEnumerableFieldName(field.name)) continue;
|
||||
if (!built.created.has(field.name) || deleted.has(field.name)) return false;
|
||||
}
|
||||
return info.subclasses.every(fixed);
|
||||
};
|
||||
return fixed(root);
|
||||
}
|
||||
@@ -3201,6 +3201,13 @@ function lowerDynReceiverMethodCall(
|
||||
const info = lowerer.classes.get(probed.type.className);
|
||||
if (info) return lowerStaticMethodCall(lowerer, call, access);
|
||||
}
|
||||
// A possibly missing element of a class-typed array (`items[i]`) is
|
||||
// the receiver's class or undefined. The class method call checks for
|
||||
// undefined itself, as it does in TypeScript.
|
||||
const presentArm =
|
||||
probed?.type.kind === "union" ? lowerer.stripUndefinedArm(probed.type) : null;
|
||||
if (presentArm?.kind === "object" && typeEquals(presentArm, lowerer.mapTypeOf(recvTs) ?? DYN))
|
||||
return null;
|
||||
if (
|
||||
probed?.type.kind === "union" &&
|
||||
isJsSourceFile(call.getSourceFile()) &&
|
||||
|
||||
@@ -53,7 +53,12 @@ import {
|
||||
} from "./instantiation-path.js";
|
||||
import { appendImplicitUndefinedReturn, hasExplicitJsDocReturn } from "./function-returns.js";
|
||||
import { bodyReadsArguments } from "../arguments-usage.js";
|
||||
import { generatorMeta, type ParamShape } from "./call-signatures.js";
|
||||
import {
|
||||
checkedJsDocumentedParameter,
|
||||
documentedParameter,
|
||||
generatorMeta,
|
||||
type ParamShape,
|
||||
} from "./call-signatures.js";
|
||||
import { omittedArgFor } from "./call-arguments.js";
|
||||
import {
|
||||
isGenericCallableMemberType,
|
||||
@@ -129,6 +134,7 @@ import { hasLexicalPrivateReference, hasStaticThis, rejectStaticThis } from "./s
|
||||
import { lowerUrlNew } from "./lower-url.js";
|
||||
import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js";
|
||||
import { classStaticDataFor } from "./class-static-data.js";
|
||||
import { construction, constructionPrefix } from "./js-class-fields.js";
|
||||
import { lowerObjectFactoryNew } from "./object-factory-new.js";
|
||||
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
|
||||
import { lowerInstanceConstructorNew } from "./class-instance-constructor.js";
|
||||
@@ -975,7 +981,8 @@ function jsFieldInitializerType(lowerer: Lowerer, type: IrType, expression: ts.E
|
||||
.some(
|
||||
(decl) =>
|
||||
ts.isParameter(decl) &&
|
||||
(ts.isConstructorDeclaration(decl.parent) ||
|
||||
((ts.isConstructorDeclaration(decl.parent) &&
|
||||
!checkedJsDocumentedParameter(lowerer, decl)) ||
|
||||
(lowerer.typeOf(expression).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0),
|
||||
)
|
||||
)
|
||||
@@ -3656,6 +3663,19 @@ export function collectClassShapeInner(
|
||||
const keySym = lateBoundKeySymOf(lowerer, p);
|
||||
if (keySym) lateBoundByKey.set(keySym, p);
|
||||
}
|
||||
// A subclass field created before anything can observe the instance
|
||||
// keeps its type: super() cannot run code that reads it earlier.
|
||||
const baseConstruction =
|
||||
base && ![...methods.keys()].some((m) => m.startsWith("get:") || m.startsWith("set:"))
|
||||
? construction(base)
|
||||
: null;
|
||||
const sealedPrefix = baseConstruction
|
||||
? constructionPrefix(
|
||||
decl,
|
||||
baseConstruction,
|
||||
(name) => !hasAccessor(name) && !methods.has(name) && !lowerer.findMethodOn(base, name),
|
||||
)
|
||||
: null;
|
||||
if (ctor?.body) {
|
||||
for (const stmt of ctor.body.statements) {
|
||||
const lhs =
|
||||
@@ -3726,7 +3746,8 @@ export function collectClassShapeInner(
|
||||
type = inheritedJsFieldType(lowerer, decl, name, type);
|
||||
// super() can invoke a subclass setter or method before its
|
||||
// constructor assignments run. These slots start undefined.
|
||||
if (base && lowerer.dynConvertible(type)) type = DYN;
|
||||
if (base && lowerer.dynConvertible(type) && !sealedPrefix?.created.has(name))
|
||||
type = DYN;
|
||||
fields.set(name, type);
|
||||
fieldOrder.push({ name, type, initializer: undefined });
|
||||
continue;
|
||||
@@ -6400,7 +6421,8 @@ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.Sou
|
||||
for (const declaration of property ? lowerer.checker.declarationsOf(property) : []) {
|
||||
if (
|
||||
ts.isMethodDeclaration(declaration) &&
|
||||
isJsSourceFile(declaration.getSourceFile())
|
||||
isJsSourceFile(declaration.getSourceFile()) &&
|
||||
!checkedJsOverride(lowerer, declaration, member)
|
||||
) {
|
||||
lowerer.virtualJsMethods.add(declaration);
|
||||
if (
|
||||
@@ -6470,6 +6492,21 @@ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.Sou
|
||||
}
|
||||
}
|
||||
|
||||
/** An override whose base and overriding methods are in files TypeScript
|
||||
* checks and document every parameter: TypeScript checks the override
|
||||
* against its base, so both keep their declared signatures, as in
|
||||
* TypeScript classes. */
|
||||
function checkedJsOverride(
|
||||
lowerer: Lowerer,
|
||||
base: ts.MethodDeclaration,
|
||||
member: ts.MethodDeclaration,
|
||||
): boolean {
|
||||
return [base, member].every(
|
||||
(method) =>
|
||||
lowerer.checkedJsFile(method.getSourceFile()) && method.parameters.every(documentedParameter),
|
||||
);
|
||||
}
|
||||
|
||||
/** The receiver's EXACT runtime class, when the expression proves it: a
|
||||
* `new C(...)` expression directly, or a const binding initialized with
|
||||
* one (the binding can never be reassigned to a subclass instance).
|
||||
|
||||
@@ -197,6 +197,7 @@ import {
|
||||
cjsExportAssignmentOf,
|
||||
cjsExportDiscardReason,
|
||||
fallbackDtsPath,
|
||||
isCheckedJsSourceFile,
|
||||
isCjsExportTableLiteral,
|
||||
isCjsJsFile,
|
||||
isJsSourceFile,
|
||||
@@ -694,6 +695,8 @@ export interface LowerResult {
|
||||
export interface LowerOptions {
|
||||
/** Parser/checker services owned by the load; required for npm graph analysis. */
|
||||
frontendServices?: FrontendServices | undefined;
|
||||
/** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */
|
||||
projectCheckJs?: boolean;
|
||||
/** Coverage: additionally lower the unreached remainder (bodies nothing
|
||||
* on the entry path reaches) in a throwaway pass and report its
|
||||
* diagnostics and stats under `unreached` — the whole-program analysis
|
||||
@@ -779,6 +782,7 @@ type RuntimeFenceTarget =
|
||||
/** The Lowerer's pass configuration (see lowerToIr). */
|
||||
export interface LowererMode {
|
||||
frontendServices?: FrontendServices | undefined;
|
||||
projectCheckJs?: boolean;
|
||||
/** Names of bodies a prior reachability pass reached; null lowers everything. */
|
||||
reachable?: ReadonlySet<string> | null;
|
||||
/** Coverage remainder: lower ONLY bodies outside `reachable`, skip the
|
||||
@@ -903,6 +907,7 @@ export function lowerToIr(
|
||||
options.externalTypeSpecifiersByFile ?? directExternalTypeSpecifiersByFile(externalTypes);
|
||||
const validation = new Lowerer(program, entry, moduleOrder, {
|
||||
frontendServices: options.frontendServices,
|
||||
projectCheckJs: options.projectCheckJs ?? false,
|
||||
targetPlatform,
|
||||
startupCrash,
|
||||
ffiImports,
|
||||
@@ -924,6 +929,7 @@ export function lowerToIr(
|
||||
? validation
|
||||
: new Lowerer(program, entry, moduleOrder, {
|
||||
frontendServices: options.frontendServices,
|
||||
projectCheckJs: options.projectCheckJs ?? false,
|
||||
targetPlatform,
|
||||
startupCrash,
|
||||
ffiImports,
|
||||
@@ -951,6 +957,7 @@ export function lowerToIr(
|
||||
if (reachableEmit.requiresHistoricalOrderRelower) {
|
||||
const emit = new Lowerer(program, entry, moduleOrder, {
|
||||
frontendServices: options.frontendServices,
|
||||
projectCheckJs: options.projectCheckJs ?? false,
|
||||
reachable,
|
||||
targetPlatform,
|
||||
startupCrash,
|
||||
@@ -1007,6 +1014,7 @@ export function lowerToIr(
|
||||
if (options.coverage !== true) return result;
|
||||
const remainder = new Lowerer(program, entry, moduleOrder, {
|
||||
frontendServices: options.frontendServices,
|
||||
projectCheckJs: options.projectCheckJs ?? false,
|
||||
reachable,
|
||||
remainder: true,
|
||||
alreadyFlushed: resultLowerer.flushedSymbols,
|
||||
@@ -2623,6 +2631,9 @@ export class Lowerer {
|
||||
readonly targetPlatform: string;
|
||||
/** LowererMode.startupCrash — buildMain opens %main with the throw. */
|
||||
readonly startupCrash: StartupCrash | null;
|
||||
/** The project's tsconfig sets `checkJs`. */
|
||||
readonly projectCheckJs: boolean;
|
||||
private readonly checkedJsFiles = new Map<ts.SourceFile, boolean>();
|
||||
/** Outbound native bindings by their source-level ambient name. */
|
||||
readonly ffiImports: readonly IrFfiImport[];
|
||||
readonly libraryCallbacks: boolean;
|
||||
@@ -2685,6 +2696,7 @@ export class Lowerer {
|
||||
this.alreadyFlushed = mode.alreadyFlushed ?? new Set();
|
||||
this.targetPlatform = mode.targetPlatform ?? process.platform;
|
||||
this.startupCrash = mode.startupCrash ?? null;
|
||||
this.projectCheckJs = mode.projectCheckJs ?? false;
|
||||
this.ffiImports = mode.ffiImports ?? [];
|
||||
this.libraryCallbacks = mode.libraryCallbacks ?? false;
|
||||
this.workerTargets = mode.workerTargets ?? [];
|
||||
@@ -2810,6 +2822,18 @@ export class Lowerer {
|
||||
registerBuiltinEventTargetClasses(this);
|
||||
}
|
||||
|
||||
/** A JavaScript file TypeScript type-checks (`// @ts-check`, or the
|
||||
* project's `checkJs`). Its JSDoc types are enforced like TypeScript
|
||||
* annotations, so its classes use them as a TypeScript file would. */
|
||||
checkedJsFile(sf: ts.SourceFile): boolean {
|
||||
let checked = this.checkedJsFiles.get(sf);
|
||||
if (checked === undefined) {
|
||||
checked = isCheckedJsSourceFile(sf, this.projectCheckJs);
|
||||
this.checkedJsFiles.set(sf, checked);
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
registerBuiltinErrorClasses(): void {
|
||||
return registerBuiltinErrorClasses(this);
|
||||
}
|
||||
|
||||
@@ -476,6 +476,7 @@ function loadFrontend(
|
||||
...opts,
|
||||
frontendServices: finalLoad.services,
|
||||
startupCrash: finalLoad.startupCrash ?? null,
|
||||
projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false,
|
||||
externalTypes: finalLoad.externalTypes,
|
||||
externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile,
|
||||
}),
|
||||
|
||||
@@ -237,6 +237,7 @@ function adoptProjectConfig7(
|
||||
diags: ScrDiagnostic[];
|
||||
projectLib?: readonly string[];
|
||||
forcedStrictNullChecks?: boolean;
|
||||
checkJs?: boolean;
|
||||
} {
|
||||
const configFile = ts.findConfigFile(dirname(entryPath), ts.sys.fileExists) ?? null;
|
||||
if (!configFile) {
|
||||
@@ -300,6 +301,7 @@ function adoptProjectConfig7(
|
||||
diags,
|
||||
...(projectLib === undefined ? {} : { projectLib }),
|
||||
forcedStrictNullChecks,
|
||||
checkJs: parsed.options["checkJs"] === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,6 +320,9 @@ export interface TypeWorldDifferences {
|
||||
projectLib: readonly string[] | null;
|
||||
/** The project disables strictNullChecks; scriptc forces it on. */
|
||||
forcedStrictNullChecks: boolean;
|
||||
/** The project's tsconfig sets `checkJs` (scriptc's own checker always
|
||||
* reads JavaScript files; this records whether the project's does). */
|
||||
projectCheckJs: boolean;
|
||||
}
|
||||
|
||||
export interface LoadResult {
|
||||
@@ -734,6 +739,7 @@ function loadProgram7(
|
||||
nodeFallback: nodeTypes === null,
|
||||
projectLib: config.projectLib ?? null,
|
||||
forcedStrictNullChecks: config.forcedStrictNullChecks ?? false,
|
||||
projectCheckJs: config.checkJs ?? false,
|
||||
},
|
||||
externalTypes,
|
||||
externalTypeSpecifiersByFile,
|
||||
@@ -988,6 +994,14 @@ export function checkPreflight<T extends LoadResult>(load: T): ScrDiagnostic[] {
|
||||
// lowering separately verifies the actual constructor implementation.
|
||||
const TS7_JS_RELAXED_EXTRA: ReadonlySet<number> = new Set([2309, 2323, 2507]);
|
||||
|
||||
/** Relaxed JavaScript codes that checked JavaScript files keep: values of
|
||||
* another type in a typed slot (2322 and its elaborations 2740/2741/2559,
|
||||
* 2345, 2769), call arity (2554-2556) and incompatible subclass members
|
||||
* (2415). */
|
||||
const CHECKED_JS_ENFORCED_CODES: ReadonlySet<number> = new Set([
|
||||
2322, 2345, 2415, 2554, 2555, 2556, 2559, 2740, 2741, 2769,
|
||||
]);
|
||||
|
||||
function suppressedJsStrictness7(d: ts.Diagnostic): boolean {
|
||||
const file = d.fileName;
|
||||
if (!file || !isJsSourceFileName(file)) return false;
|
||||
@@ -3492,6 +3506,16 @@ function preflight7(load: LoadResult): {
|
||||
: spec.split("/")[0]!;
|
||||
return isWorkspacePackageName(prefix);
|
||||
};
|
||||
// A JavaScript file that TypeScript checks keeps the type errors that
|
||||
// protect its JSDoc types: a value of another type reaching a typed slot,
|
||||
// a call with the wrong arity, and a subclass member that does not
|
||||
// match its base.
|
||||
const projectCheckJs = load.typeWorld?.projectCheckJs ?? false;
|
||||
const enforcedInCheckedJs = (p: ts.Program, d: ts.Diagnostic): boolean => {
|
||||
if (!CHECKED_JS_ENFORCED_CODES.has(d.code) || d.fileName === undefined) return false;
|
||||
const sf = p.getSourceFile(d.fileName);
|
||||
return sf !== undefined && isCheckedJsSourceFile(sf, projectCheckJs);
|
||||
};
|
||||
const errorsOf = (p: ts.Program): ts.Diagnostic[] => {
|
||||
const all = ts.getPreEmitDiagnostics(p);
|
||||
// First pass: every comment-side 2300's (file, name) — the partners
|
||||
@@ -3508,7 +3532,7 @@ function preflight7(load: LoadResult): {
|
||||
return all.filter(
|
||||
(d) =>
|
||||
d.category === ts.DiagnosticCategory.Error &&
|
||||
!suppressedJsStrictness7(d) &&
|
||||
(!suppressedJsStrictness7(d) || enforcedInCheckedJs(p, d)) &&
|
||||
!npmStaticFileSuppressed(d) &&
|
||||
!nodeModulesJsSuppressed(d) &&
|
||||
!namespaceCalleeSuppressed(p, d) &&
|
||||
@@ -4915,6 +4939,21 @@ export function isJsSourceFile(sf: ts.SourceFile): boolean {
|
||||
return isJsSourceFileName(sf.fileName);
|
||||
}
|
||||
|
||||
/** True for a JavaScript file of the program that TypeScript type-checks:
|
||||
* one with a `// @ts-check` comment before its first statement, or any
|
||||
* file of a project whose tsconfig sets `checkJs` unless the file has a
|
||||
* `// @ts-nocheck` comment. Files under node_modules are never checked. */
|
||||
export function isCheckedJsSourceFile(sf: ts.SourceFile, projectCheckJs: boolean): boolean {
|
||||
if (!isJsSourceFile(sf) || isNodeModulesPath(sf.fileName)) return false;
|
||||
let checked = projectCheckJs;
|
||||
const head = sf.text.slice(0, sf.statements[0]?.getStart(sf) ?? sf.text.length);
|
||||
for (const line of head.split("\n")) {
|
||||
const directive = /^\s*\/\/\s*@ts-(no)?check\b/.exec(line);
|
||||
if (directive) checked = directive[1] === undefined;
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
/** `import type …` declarations and named clauses whose every element is
|
||||
* `type`-qualified are ERASED by tsc at emit — Node's module graph has no
|
||||
* edge there, so module order, cycle detection, init calls, and the
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule, validateModule } from "../src/index.js";
|
||||
import type { IrModule } from "../src/ir/ir.js";
|
||||
|
||||
async function lower(source: string): Promise<IrModule> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-js-class-layouts-"));
|
||||
try {
|
||||
const entry = join(dir, "main.js"),
|
||||
output = join(dir, "main.ir.json");
|
||||
await writeFile(entry, source);
|
||||
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
|
||||
const module = deserializeModule(await readFile(output, "utf8"));
|
||||
expect(validateModule(module)).toEqual([]);
|
||||
return module;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Field name → IR kind for a class, and whether it tracks field creation. */
|
||||
function layout(module: IrModule, name: string): { fields: string; tracks: boolean } {
|
||||
const cls = module.classes.find((candidate) => candidate.name === name);
|
||||
expect(cls, name).toBeDefined();
|
||||
return {
|
||||
fields: cls!.fields
|
||||
.filter((field) => !field.name.startsWith("%"))
|
||||
.map((field) => `${field.name}:${field.type.kind}`)
|
||||
.join(" "),
|
||||
tracks: cls!.tracksOwnFields === true,
|
||||
};
|
||||
}
|
||||
|
||||
function params(module: IrModule, name: string): string {
|
||||
const fn = module.functions.find((candidate) => candidate.name === name);
|
||||
expect(fn, name).toBeDefined();
|
||||
return `(${fn!.params.map((param) => param.type.kind).join(", ")}): ${fn!.returnType.kind}`;
|
||||
}
|
||||
|
||||
function calls(module: IrModule, name: string): string[] {
|
||||
const fn = module.functions.find((candidate) => candidate.name === name);
|
||||
expect(fn, name).toBeDefined();
|
||||
return [...JSON.stringify(fn!.body).matchAll(/"callee":"([^"]+)"/g)].map((match) => match[1]!);
|
||||
}
|
||||
|
||||
const SHAPES = `
|
||||
class Shape {
|
||||
constructor() {
|
||||
this.sides = 0;
|
||||
this.label = "shape";
|
||||
}
|
||||
reset() {
|
||||
this.sides = 0;
|
||||
}
|
||||
}
|
||||
class Square extends Shape {
|
||||
constructor() {
|
||||
super();
|
||||
this.sides = 4;
|
||||
this.size = 2;
|
||||
}
|
||||
}
|
||||
const shapes = [new Shape(), new Square()];
|
||||
for (let i = 0; i < shapes.length; i++) shapes[i].reset();
|
||||
console.log(shapes.length);
|
||||
`;
|
||||
|
||||
test("JavaScript fields created before the instance escapes need no creation tracking", async () => {
|
||||
const module = await lower(SHAPES);
|
||||
expect(layout(module, "Shape")).toEqual({ fields: "sides:f64 label:string", tracks: false });
|
||||
expect(layout(module, "Square")).toEqual({
|
||||
fields: "sides:f64 label:string size:f64",
|
||||
tracks: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("JavaScript fields created after the instance escapes keep creation tracking", async () => {
|
||||
const escaped = await lower(`
|
||||
class Node {
|
||||
constructor(list) {
|
||||
list.push(this);
|
||||
this.id = list.length;
|
||||
}
|
||||
touch() {
|
||||
this.id++;
|
||||
}
|
||||
}
|
||||
const node = new Node([]);
|
||||
node.touch();
|
||||
console.log(node.id);
|
||||
`);
|
||||
expect(layout(escaped, "Node").tracks).toBe(true);
|
||||
const deleted = await lower(`${SHAPES}
|
||||
const loose = /** @type {any} */ (shapes[0]);
|
||||
delete loose.label;
|
||||
`);
|
||||
expect(layout(deleted, "Shape").tracks).toBe(true);
|
||||
});
|
||||
|
||||
test("JavaScript subclass fields keep their types when the base constructor only assigns fields", async () => {
|
||||
const module = await lower(`
|
||||
class Base {
|
||||
constructor() {
|
||||
this.ready = true;
|
||||
this.report();
|
||||
}
|
||||
report() {}
|
||||
}
|
||||
class Late extends Base {
|
||||
constructor() {
|
||||
super();
|
||||
this.count = 1;
|
||||
}
|
||||
}
|
||||
console.log(new Late().count);
|
||||
${SHAPES}`);
|
||||
expect(layout(module, "Square").fields).toBe("sides:f64 label:string size:f64");
|
||||
expect(layout(module, "Late").fields).toBe("ready:bool count:dyn");
|
||||
});
|
||||
|
||||
test("method calls on possibly missing JavaScript array elements dispatch natively", async () => {
|
||||
const module = await lower(SHAPES);
|
||||
expect(calls(module, "%init.0")).toContain("%Shape.reset");
|
||||
expect(calls(module, "%init.0").some((callee) => callee.startsWith("%dyn.class.call"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const ACCOUNTS = `
|
||||
class Account {
|
||||
/** @param {string} owner @param {number} balance */
|
||||
constructor(owner, balance) {
|
||||
this.owner = owner;
|
||||
this.balance = balance;
|
||||
}
|
||||
/** @returns {number} */
|
||||
fee() {
|
||||
return this.balance > 100 ? 0 : 1;
|
||||
}
|
||||
close() {
|
||||
this.balance = 0;
|
||||
}
|
||||
}
|
||||
class Savings extends Account {
|
||||
/** @param {string} owner @param {number} balance */
|
||||
constructor(owner, balance) {
|
||||
super(owner, balance);
|
||||
this.rate = 2;
|
||||
}
|
||||
/** @returns {number} */
|
||||
fee() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/** @type {Account[]} */
|
||||
const accounts = [new Account("a", 1), new Savings("b", 2)];
|
||||
let fees = 0;
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
fees += accounts[i].fee();
|
||||
accounts[i].close();
|
||||
}
|
||||
console.log(fees);
|
||||
`;
|
||||
|
||||
test("a JavaScript file TypeScript checks uses its JSDoc types for class layouts", async () => {
|
||||
const checked = await lower(`// @ts-check\n${ACCOUNTS}`);
|
||||
expect(layout(checked, "Account").fields).toBe("owner:string balance:f64");
|
||||
expect(params(checked, "%Account.constructor")).toBe("(object, string, f64): void");
|
||||
expect(params(checked, "%Savings.fee")).toBe("(object): f64");
|
||||
expect(calls(checked, "%init.0").some((callee) => callee.startsWith("%dyn.class."))).toBe(false);
|
||||
const unchecked = await lower(ACCOUNTS);
|
||||
expect(layout(unchecked, "Account").fields).toBe("owner:dyn balance:dyn");
|
||||
expect(params(unchecked, "%Account.constructor")).toBe("(object, dyn, dyn): void");
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5934-js-class-fields-property-order.js"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5935-js-subclass-fields-and-element-calls.js"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5936-checked-js-class-types.js"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/checked-js-constructor-arguments.js"
|
||||
],
|
||||
"diags": [
|
||||
{
|
||||
"code": "SC0001",
|
||||
"message": "Argument of type 'string' is not assignable to parameter of type 'number'.",
|
||||
"loc": {
|
||||
"file": "<repo>/tests/diagnostics/checked-js-constructor-arguments.js",
|
||||
"start": 272,
|
||||
"end": 276
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Property order of JavaScript class instances that also hold added
|
||||
// properties. Fields assigned at the top of a constructor exist from
|
||||
// construction, so they come first in their assignment order; fields
|
||||
// created later, or after the instance was passed somewhere, keep their
|
||||
// creation position among the added properties.
|
||||
class Point {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
move() {
|
||||
this.x++;
|
||||
}
|
||||
}
|
||||
|
||||
class Point3 extends Point {
|
||||
constructor(x, y, z) {
|
||||
super(x, y);
|
||||
this.z = z;
|
||||
}
|
||||
}
|
||||
|
||||
class Late {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.setup();
|
||||
this.after = 1;
|
||||
}
|
||||
setup() {
|
||||
this.extra = "set up";
|
||||
}
|
||||
}
|
||||
|
||||
class Registered {
|
||||
constructor(list) {
|
||||
list.push(this);
|
||||
this.id = list.length;
|
||||
}
|
||||
}
|
||||
|
||||
class Derived extends Late {
|
||||
constructor() {
|
||||
super("derived");
|
||||
this.own = true;
|
||||
}
|
||||
}
|
||||
|
||||
class Declared {
|
||||
label = "declared";
|
||||
constructor(count) {
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} target @param {string} key @param {unknown} value */
|
||||
function put(target, key, value) {
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
const p = new Point(1, 2);
|
||||
put(p, "tag", "a");
|
||||
p.move();
|
||||
console.log(Object.keys(p));
|
||||
for (const key in p) console.log("key", key);
|
||||
|
||||
|
||||
const q = new Point3(3, 4, 5);
|
||||
put(q, "w", 6);
|
||||
q.move();
|
||||
console.log(Object.keys(q));
|
||||
|
||||
const late = new Late("first");
|
||||
put(late, "added", 2);
|
||||
console.log(Object.keys(late));
|
||||
|
||||
const list = [];
|
||||
const r = new Registered(list);
|
||||
put(r, "note", "n");
|
||||
console.log(Object.keys(r));
|
||||
|
||||
const d = new Derived();
|
||||
put(d, "more", 3);
|
||||
console.log(Object.keys(d));
|
||||
|
||||
const c = new Declared(4);
|
||||
put(c, "next", 5);
|
||||
console.log(Object.keys(c));
|
||||
|
||||
p.move = function () {
|
||||
this.y--;
|
||||
};
|
||||
p.move();
|
||||
console.log(p.x, p.y, Object.keys(p));
|
||||
@@ -0,0 +1,71 @@
|
||||
// JavaScript subclass fields keep their assigned types when the base
|
||||
// constructor only assigns fields, and stay undefined until assigned when
|
||||
// the base constructor calls an overridden method first. Calls on a
|
||||
// possibly missing array element throw Node's TypeError.
|
||||
class Shape {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.sides = 0;
|
||||
}
|
||||
area() {
|
||||
return 0;
|
||||
}
|
||||
describe() {
|
||||
return `${this.name} with ${this.sides} sides, area ${this.area()}`;
|
||||
}
|
||||
}
|
||||
|
||||
class Rect extends Shape {
|
||||
constructor(width, height) {
|
||||
super("rect");
|
||||
this.sides = 4;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
area() {
|
||||
return this.width * this.height;
|
||||
}
|
||||
}
|
||||
|
||||
class Square extends Rect {
|
||||
constructor(size) {
|
||||
super(size, size);
|
||||
this.name = "square";
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
class Reporter {
|
||||
constructor() {
|
||||
this.log = [];
|
||||
this.report();
|
||||
}
|
||||
report() {
|
||||
this.log.push("base");
|
||||
}
|
||||
}
|
||||
|
||||
class EarlyReporter extends Reporter {
|
||||
constructor() {
|
||||
super();
|
||||
this.label = "early";
|
||||
this.count = 1;
|
||||
}
|
||||
report() {
|
||||
console.log("before assignment:", this.label, this.count);
|
||||
}
|
||||
}
|
||||
|
||||
const shapes = [new Shape("dot"), new Rect(2, 3), new Square(4)];
|
||||
let total = 0;
|
||||
for (let i = 0; i < shapes.length; i++) total += shapes[i].area();
|
||||
for (let i = 0; i < shapes.length; i++) console.log(shapes[i].describe());
|
||||
console.log(total, Object.keys(shapes[2]));
|
||||
try {
|
||||
shapes[7].describe();
|
||||
} catch (error) {
|
||||
console.log(error instanceof TypeError, error.message);
|
||||
}
|
||||
const reporter = new EarlyReporter();
|
||||
reporter.report();
|
||||
console.log(Object.keys(reporter));
|
||||
@@ -0,0 +1,68 @@
|
||||
// @ts-check
|
||||
// A JavaScript file TypeScript checks uses its JSDoc types for class
|
||||
// layouts: typed constructor parameters and fields, method overrides with
|
||||
// documented parameters, and methods that only a typed write replaces.
|
||||
class Account {
|
||||
/**
|
||||
* @param {string} owner
|
||||
* @param {number} balance
|
||||
*/
|
||||
constructor(owner, balance) {
|
||||
this.owner = owner;
|
||||
this.balance = balance;
|
||||
this.history = /** @type {number[]} */ ([]);
|
||||
}
|
||||
/** @param {number} amount */
|
||||
deposit(amount) {
|
||||
this.balance += amount;
|
||||
this.history.push(amount);
|
||||
}
|
||||
/** @returns {number} */
|
||||
fee() {
|
||||
return 1;
|
||||
}
|
||||
close() {
|
||||
this.balance = 0;
|
||||
}
|
||||
/** @returns {string} */
|
||||
summary() {
|
||||
return `${this.owner}: ${this.balance} (fee ${this.fee()}, ${this.history.length} deposits)`;
|
||||
}
|
||||
}
|
||||
|
||||
class Savings extends Account {
|
||||
/**
|
||||
* @param {string} owner
|
||||
* @param {number} balance
|
||||
* @param {number} rate
|
||||
*/
|
||||
constructor(owner, balance, rate) {
|
||||
super(owner, balance);
|
||||
this.rate = rate;
|
||||
}
|
||||
/** @returns {number} */
|
||||
fee() {
|
||||
return 0;
|
||||
}
|
||||
/** @param {number} amount */
|
||||
deposit(amount) {
|
||||
super.deposit(amount + amount * this.rate);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Account[]} */
|
||||
const accounts = [new Account("ann", 10), new Savings("bo", 100, 0.5)];
|
||||
for (let round = 0; round < 3; round++)
|
||||
for (let i = 0; i < accounts.length; i++) accounts[i].deposit(round + 1);
|
||||
for (const account of accounts) console.log(account.summary());
|
||||
let fees = 0;
|
||||
for (let i = 0; i < accounts.length; i++) fees += accounts[i].fee();
|
||||
console.log(fees, Object.keys(accounts[1]));
|
||||
|
||||
const closing = new Account("cy", 5);
|
||||
closing.close = function () {
|
||||
this.balance = -1;
|
||||
};
|
||||
closing.close();
|
||||
accounts[0].close();
|
||||
console.log(closing.balance, accounts[0].balance);
|
||||
@@ -0,0 +1,12 @@
|
||||
// @ts-check
|
||||
// A JavaScript file TypeScript checks reports the type errors that protect
|
||||
// its JSDoc types, as a TypeScript file does.
|
||||
class Account {
|
||||
/** @param {number} balance */
|
||||
constructor(balance) {
|
||||
this.balance = balance;
|
||||
}
|
||||
}
|
||||
const account = new Account("10");
|
||||
console.log(account.balance);
|
||||
// End with a nonblank context line so diagnostic snapshots carry no trailing whitespace.
|
||||
@@ -0,0 +1,6 @@
|
||||
checked-js-constructor-arguments.js:10:29 - error SC0001: Argument of type 'string' is not assignable to parameter of type 'number'.
|
||||
|
||||
9 | }
|
||||
10 | const account = new Account("10");
|
||||
| ^~~~
|
||||
11 | console.log(account.balance);
|
||||
Reference in New Issue
Block a user