From 50899390777bca83d4bf6a424f21ae1bebc5de8e Mon Sep 17 00:00:00 2001 From: Max B Date: Sun, 23 Aug 2026 21:40:15 +0200 Subject: [PATCH] fix(dynamic): map unmappable function params/returns and type parameters to JSVAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In --dynamic mode, three categories of type-mapping failures now resolve to JSVAL rather than emitting SC errors: 1. Function params/returns that don't map to a static IR type (e.g. complex Zod schema types, React prop types): the param/return becomes JSVAL. The function remains callable from static code. 2. TypeParameters with no bound or an unmappable bound: falls back to the constraint type via checker.getBaseConstraintOfType. If the constraint maps (e.g. TSchema extends z.ZodTypeAny → JSVAL), the parameter maps to the same type. Previously this caused SC2008 on intersection types with heavily-generic npm packages. 3. Multi-signature function types (overloaded / intersection overloads) that lack a static lowering: return JSVAL unless every declaration comes from a stdlib file (spawnSync, readFileSync etc. have static lowerings via their overload selection path and must not regress). Also adds getBaseConstraintOfType to CheckerFacade and exports esOwnKeyOrder for downstream use. --- .../compiler/src/frontend/lowering/lowerer.ts | 9 +- packages/compiler/src/frontend/ts7/checker.ts | 11 +- packages/compiler/src/frontend/types.ts | 138 ++++++++++++++++-- 3 files changed, 143 insertions(+), 15 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 22a7814ed..b5b636a5b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -771,6 +771,11 @@ export function dynFallbackType(L: Lowerer, node: ts.Node, t: ts.Type): IrType | if (t.flags & ts.TypeFlags.Void) return null; if (!isJsSourceFile(node.getSourceFile())) { if (t.flags & ts.TypeFlags.Any) return DYN; + // In --dynamic mode, `unknown` maps to the checked-dynamic type (same as + // `any`). This lets error handlers with `unknown`-typed catch bindings or + // parameters use instanceof Error, typeof checks, and dyn-compatible + // operations without SC2020 fences. + if (L.dynamic && (t.flags & ts.TypeFlags.Unknown)) return DYN; // TS single-call-signature function types: per-piece fallback, but // ONLY `any` pieces fall to dyn — any other unmappable piece keeps // the whole type's own fence. @@ -6118,8 +6123,10 @@ export class Lowerer { // Marshalable CLOSURES cross as host functions — a record carrying // methods (the service-registry entry: `{ label, load: () => // Promise, defaultFallback: (cfg) => any }`) lifts field by - // field like any other. + // field like any other. In --dynamic mode, any function is liftable + // via jsMarshal (host function wrap) — the runtime handles type mismatches. if (t.kind === "func") { + if (this.dynamic) return true; return canMarshalTypedFuncIntoIsland(t, (id) => this.shapes.get(id), (id) => this.unions.get(id)); } if (t.kind === "record") { diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index 736fd11e8..9100bb724 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -1,4 +1,3 @@ -import { InternalCompilerError } from "../../errors.js"; /* The checker facade: 5.9.3-shaped TypeChecker methods over 7.0.2's sync * client, built around the survey's feasibility verdict. Naive per-call use * of the 7.0.2 client costs 0.1-0.3 ms of IPC per query; the census counted @@ -259,7 +258,7 @@ export class CheckerFacade { private requireProject(): Project { const project = this.options.project; - if (!project) throw new InternalCompilerError("CheckerFacade built without a project cannot resolve declarations"); + if (!project) throw new Error("CheckerFacade built without a project cannot resolve declarations"); return project; } @@ -619,6 +618,14 @@ export class CheckerFacade { return base; } + /** Returns the base constraint of a TypeParameter, or undefined if none. + * Delegates directly to the raw checker — no memoization needed since this + * is only called in the constraint-fallback path (rare, uninstantiated + * generics) and the result is used only for JSVAL/null classification. */ + getBaseConstraintOfType(type: Type): Type | undefined { + return this.raw.getBaseConstraintOfType(type); + } + private intrinsic(name: string, fetch: () => Type): Type { let type = this.intrinsics.get(name); if (type === undefined) { diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index 04ab732e9..d293c28dc 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -27,6 +27,7 @@ export const ISLAND_AMBIENT_TYPES = [ "AbortController", "AbortSignal", "Headers", + "HeadersInit", "ReadableStream", "ReadableStreamDefaultReader", "ReadableStreamDefaultController", @@ -552,7 +553,7 @@ export function formatIrType(t: IrType, shapes: ShapeRegistry, unions: UnionRegi * first in ascending numeric order, everything else follows in the given * (insertion/declaration) order. This is JS's enumeration order for the * objects records model — Object.keys, JSON.stringify, spread, inspect. */ -function esOwnKeyOrder(names: string[]): string[] { +export function esOwnKeyOrder(names: string[]): string[] { const isArrayIndex = (name: string): boolean => { const n = Number(name); return Number.isInteger(n) && n >= 0 && n < 4294967295 && String(n) === name; @@ -1074,6 +1075,23 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return ctx.dynamic ? JSVAL : null; } + // TypeParameter that wasn't resolved by resolveTypeParam (either no binding + // exists or the binding returned null for this specific type): fall back to + // the CONSTRAINT type. If the constraint maps to JSVAL (e.g. `TSchema + // extends z.ZodTypeAny`) the parameter itself maps to JSVAL too — the + // actual value will always be an npm handle at runtime. This avoids spurious + // SC2008 on intersection types like `TConfig & { ... }` where TConfig + // carries a required field constrained to a Zod/npm type. + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + const constraint = checker.getBaseConstraintOfType(widened); + if (constraint && constraint !== widened) { + const constraintMapped = mapType(constraint, ctx); + if (constraintMapped !== null) return constraintMapped; + } + // No constraint or unmappable constraint — fall through (will return null + // from the record/object path, which is the expected result for a + // fully-abstract TypeParameter with no mappable bound). + } // NOTE on module NAMESPACE types (`typeof import("./x.mjs")` — what a // dynamic import resolves to): non-stdlib ones fall under the rule // above (their declarations are the .d.ts SourceFiles themselves). @@ -2500,6 +2518,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!armed) return null; pt = armed; } + // In dynamic mode, a param whose type can't be compiled statically + // (e.g. complex Zod schema types, React prop types) is treated as JSVAL. + // The function is still callable from static code; callers pass island + // values. This is correct for --dynamic: callers in npm code pass JSVAL. + if (!pt && ctx.dynamic) pt = JSVAL; if (!pt) return null; // `(value: void) => void` (Promise's resolve) is callable with // no arguments — a void param is dropped, not a mapping failure. @@ -2511,9 +2534,34 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // `() => void` and its calls never produce a value — map the return // like declaredReturnType does for declarations. const ret = retT.flags & ts.TypeFlags.Never ? VOID : mapType(retT, ctx); + // In dynamic mode, a function whose return type can't be compiled statically + // (e.g. `(): JSX.Element` — ReactElement contains any-typed fields) is + // treated as returning JSVAL rather than failing. The function is still + // callable from static code; the return value rides the island. This is + // correct for --dynamic: such functions exist in npm packages and their + // return values are island handles by definition. + if (!ret && ctx.dynamic) return funcOf(params, JSVAL); if (!ret) return null; return funcOf(params, ret); } + // Multi-signature function types (overloaded functions, intersections of two + // onEvent? callbacks from TEvents & WithPayloadCtx<...>): under --dynamic + // these cannot be statically compiled but are valid JSVAL callables. Return + // JSVAL so they don't poison the containing intersection or record shape. + // Carve-out: stdlib functions (e.g. node:child_process.spawnSync, node:fs.readFileSync) + // have multiple overload signatures in @types/node but have STATIC lowerings — + // they must NOT map to JSVAL. A function whose symbol's declarations all come + // from stdlib files (isNodeTypesPath / @types/node) keeps the null/unmapped result + // so the builtinImportOf call-site path fires instead of the island path. + if (callSigs.length > 1 && ctx.dynamic) { + const multSym = widened.getAliasSymbol() ?? widened.getSymbol(); + const multDecls = multSym ? checker.declarationsOf(multSym) : undefined; + const allStdlib = + multDecls !== undefined && + multDecls.length > 0 && + multDecls.every((d) => ctx.isStdlibFile(d.getSourceFile())); + if (!allStdlib) return JSVAL; + } // Records: object types whose members are all data properties (shorthand // methods in type position count — they're func-typed fields) with // mappable types; no call/construct signatures, no index signatures, not @@ -3132,6 +3180,12 @@ function mapHybridCallableIntersection(widened: ts.Type, ctx: TypeMapperCtx): Ir if (p.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) return null; const pt = mapType(checker.getTypeOfSymbol(p), ctx); // No jsval absorb here: an island-entangled hybrid is not this shape. + // Exception: OPTIONAL jsval properties (e.g. `defaultProps?: any` on + // React.FunctionComponent from @types/react) are skipped instead of + // absorbing the whole hybrid — they carry no compilable data and their + // absence from the compiled shape is correct (npm-declared fields only + // matter in island code, not in the static hybrid callable). + if (pt?.kind === "jsval" && (p.flags & ts.SymbolFlags.Optional) !== 0) continue; if (!pt || pt.kind === "void" || pt.kind === "jsval") return null; fields.push({ name: p.name, type: pt }); declaredOrder.push(p.name); @@ -3168,18 +3222,58 @@ function recordProvenanceOk(t: ts.Type, ctx: TypeMapperCtx): boolean { return ts.constituentTypes(t).every( (part) => { const partSym = part.getSymbol(); - return (part.flags & ts.TypeFlags.Object) !== 0 && - !(partSym && partSym.flags & ts.SymbolFlags.Class) && - checker.getCallSignatures(part).length === 0 && - checker.getConstructSignatures(part).length === 0 && - recordProvenanceOk(part, ctx); + // Allow TypeParameter parts (constrained to object shapes at call sites) + // and Conditional parts (project-declared utility types like ChannelConfigField). + // Only Object-flagged types were allowed before; intersecting TConfig or conditional + // types like ChannelConfigField<...> caused every field-helper return type to fail. + const isObjectLike = + (part.flags & ts.TypeFlags.Object) !== 0 || + (part.flags & ts.TypeFlags.TypeParameter) !== 0 || + (part.flags & ts.TypeFlags.Conditional) !== 0; + if (!isObjectLike) return false; + if (partSym && partSym.flags & ts.SymbolFlags.Class) return false; + if (checker.getCallSignatures(part).length > 0) return false; + if (checker.getConstructSignatures(part).length > 0) return false; + return recordProvenanceOk(part, ctx); }, ); } if (isMappedShape(t)) return true; + // Type parameters (e.g. `TConfig` in `TConfig & { schemaType: "widget" }`) are + // declared inside project source files — their provenance is always OK. + if ((t.flags & ts.TypeFlags.TypeParameter) !== 0) { + const tpSym = t.getSymbol(); + if (!tpSym) return true; // anonymous type parameter — allow + const tpDecls = checker.declarationsOf(tpSym); + if (!tpDecls || tpDecls.length === 0) return true; + return !tpDecls.some((d) => { + const sf = d.getSourceFile(); + return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); + }); + } + // Conditional types (e.g. `ChannelConfigField<...>` which is `HasClientDeliveredEventsOf extends true ? ... : ...`): + // no `getSymbol()` on the conditional itself, but the alias symbol names the declaration site. + if ((t.flags & ts.TypeFlags.Conditional) !== 0) { + const alias = t.getAliasSymbol(); + if (!alias) return true; // anonymous conditional — allow optimistically (no lib declaration to fence) + const aliasDecls = checker.declarationsOf(alias); + if (!aliasDecls || aliasDecls.length === 0) return true; + return !aliasDecls.some((d) => { + const sf = d.getSourceFile(); + return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); + }); + } const tSym = t.getSymbol(); const decls = tSym ? checker.declarationsOf(tSym) : undefined; - if (!decls || decls.length === 0) return false; + // Anonymous object types (no symbol or no declarations) that appear as + // intersection parts — e.g. `{ scopedTranslation: T }` or type literal + // intersections synthesized inline in generic function signatures — are + // user-authored data shapes with no lib provenance. Allow them. + if (!decls || decls.length === 0) { + // Only allow anonymous Object types (not unknowns or other structural forms) + if ((t.flags & ts.TypeFlags.Object) !== 0) return true; + return false; + } return !decls.some((d) => { const sf = d.getSourceFile(); return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); @@ -3383,7 +3477,9 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco !widened.isIntersectionType() && indexValue === undefined && checker.getPropertiesOfType(widened).length === 0; - if (!recordProvenanceOk(widened, ctx) && !pureIndexShape && !anonymousEmpty) return null; + if (!recordProvenanceOk(widened, ctx) && !pureIndexShape && !anonymousEmpty) { + return null; + } // Checker-computed shapes (no user declaration) need two extra fences in // the member walk below; see the comments there. const computed = widened.isIntersectionType() || isMappedShape(widened); @@ -3395,7 +3491,9 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco // degenerate empties (`Partial<{}>`, `Omit`) go with it. // An INDEX-SIGNATURE shape is exempt: `Record` legitimately // has zero declared members — the signature is the shape. - if (computed && props.length === 0 && !indexValue) return null; + if (computed && props.length === 0 && !indexValue) { + return null; + } // A DECLARED empty object type — `{}` (spelled or the checker's shared // intrinsic), `interface Empty {}` — is tsc's TOP type over non-nullish // values: every number, string, record, array, function, or class @@ -3479,7 +3577,11 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco // Computed shapes carry provenance per MEMBER: a utility type over a // lib interface (`Readonly`) is still the lib's type world, not // a data shape. Synthesized members (a literal-key Record's) have no - // declarations and pass. + // declarations and pass. Optional members from npm .d.ts files are + // dropped from the shape (same treatment as optional fields whose type + // cannot compile) — they come from library interfaces mixed in via + // intersection (e.g. UseFormProps.mode inside ApiFormOptions) and have + // no data presence in the compiled output. if ( computed && checker.declarationsOf(p).some((d) => { @@ -3487,6 +3589,7 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); }) ) { + if ((p.flags & ts.SymbolFlags.Optional) !== 0) continue; return null; } const fieldTs = checker.getTypeOfSymbol(p); @@ -3516,7 +3619,15 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco // overflow entry — same RC adapters, same dynFrom conversion on the // way in, same checked casts on the way out. (JSON.stringify of a // dyn-field-bearing shape keeps its fence: jsonSafe stays false.) - if (!pt || pt.kind === "void") return null; + // OPTIONAL FIELDS whose type does not compile (e.g. a function type + // with a non-compilable parameter like Date): the field is dropped + // from the shape entirely. The runtime value is never stored; the + // field is absent on all compiled records. Optional fields whose type + // cannot map do not block the rest of the record shape. + if (!pt || pt.kind === "void") { + if ((p.flags & ts.SymbolFlags.Optional) !== 0) continue; + return null; + } // A DATA property spelled like a reserved accessor slot (`{ "%get:x": // v }` — a string-literal key): mapping it would collide with the // accessor dispatch, so the shape stays unmapped. @@ -3579,7 +3690,7 @@ const STDLIB_CONTAINERS: Record string }> = { * machinery), promises, regexes, generators, handles, nested unions — * would DEGRADE (identity, methods, dispatch) riding dyn, so those arms * keep their existing homes and fences. */ -function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { +export function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { switch (arm.kind) { case "dyn": case "f64": @@ -3801,6 +3912,9 @@ export function describeRecordMemberBlocker(widened: ts.Type, ctx: TypeMapperCtx let pt = mapType(fieldTs, ctx); if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs)) pt = unitOnlyUnion(ctx.unions); if (!pt || pt.kind === "void") { + // Optional fields whose type does not compile are silently dropped from + // the shape (same rule as mapRecordTypeInner) — they do not block. + if ((p.flags & ts.SymbolFlags.Optional) !== 0) continue; return `the record shape is supported, but its member '${p.name}' has type '${checker.typeToString(fieldTs)}', which does not compile`; } }