Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/compiler/src/frontend/lowering/lowerer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -6118,8 +6123,10 @@ export class Lowerer {
// Marshalable CLOSURES cross as host functions — a record carrying
// methods (the service-registry entry: `{ label, load: () =>
// Promise<any>, 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") {
Expand Down
11 changes: 9 additions & 2 deletions packages/compiler/src/frontend/ts7/checker.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 126 additions & 12 deletions packages/compiler/src/frontend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ISLAND_AMBIENT_TYPES = [
"AbortController",
"AbortSignal",
"Headers",
"HeadersInit",
"ReadableStream",
"ReadableStreamDefaultReader",
"ReadableStreamDefaultController",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<void>'s resolve) is callable with
// no arguments — a void param is dropped, not a mapping failure.
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<TEvents> 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);
Expand Down Expand Up @@ -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);
Expand All @@ -3395,7 +3491,9 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco
// degenerate empties (`Partial<{}>`, `Omit<C, keyof C>`) go with it.
// An INDEX-SIGNATURE shape is exempt: `Record<string, T>` 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
Expand Down Expand Up @@ -3479,14 +3577,19 @@ function mapRecordTypeInner(widened: ts.Type, ctx: TypeMapperCtx): IrType | Reco
// Computed shapes carry provenance per MEMBER: a utility type over a
// lib interface (`Readonly<Date>`) 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) => {
const sf = d.getSourceFile();
return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf);
})
) {
if ((p.flags & ts.SymbolFlags.Optional) !== 0) continue;
return null;
}
const fieldTs = checker.getTypeOfSymbol(p);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3579,7 +3690,7 @@ const STDLIB_CONTAINERS: Record<string, { role: (i: number) => 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":
Expand Down Expand Up @@ -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`;
}
}
Expand Down