Compile SDK-declared data types, any-valued members and Pick views of classes (#1466)

This commit is contained in:
Chris Tate
2026-10-20 18:18:56 -05:00
committed by GitHub
29 changed files with 825 additions and 57 deletions
@@ -6,6 +6,8 @@ export class ClassProtocols {
private implemented: Set<ts.Symbol> | null = null;
private readonly methods = new Map<string, ts.ClassLikeDeclaration[]>();
private readonly answers = new Map<ts.Type, boolean>();
/** The program classes each Pick or Omit view accepts (classesAcceptedBy). */
private readonly accepted = new Map<ts.Type, ts.Symbol[]>();
/** What markIdentityFlows learned about the program, while it runs. */
private facts: FlowFacts | null = null;
@@ -414,6 +416,14 @@ export class ClassProtocols {
for (const part of ts.constituentTypes(type)) add(part);
return;
}
// A Pick or Omit view's values are instances of the class it views,
// or of any other program class that has the view's members.
if (this.pickOfProgramClass(type)) {
add(type.getAliasTypeArguments()[0]!);
for (const other of this.classesAcceptedBy(type))
for (const related of this.classFamily(other, facts)) out.add(related);
return;
}
const symbol = type.getSymbol();
if (symbol === undefined) return;
if (this.isProgramClassInstance(type)) {
@@ -429,6 +439,30 @@ export class ClassProtocols {
return out;
}
/** The program classes with methods whose instances are assignable to
* `type`. */
private classesAcceptedBy(type: ts.Type): ts.Symbol[] {
const known = this.accepted.get(type);
if (known !== undefined) return known;
this.index();
const found = new Set<ts.Symbol>();
for (const entries of this.methods.values())
for (const declaration of entries) {
const symbol = declaration.name
? this.checker.getSymbolAtLocation(declaration.name)
: undefined;
if (
symbol !== undefined &&
!found.has(symbol) &&
this.checker.isTypeAssignableTo(this.checker.getDeclaredTypeOfSymbol(symbol), type)
)
found.add(symbol);
}
const result = [...found];
this.accepted.set(type, result);
return result;
}
/** A class symbol with its base classes and the program classes that
* extend it. */
private classFamily(symbol: ts.Symbol, facts: FlowFacts): ts.Symbol[] {
@@ -570,6 +604,20 @@ export class ClassProtocols {
}
}
/** `Pick<C, K>` or `Omit<C, K>` over a program class instance type: a
* view of the class's own members, spelled inline instead of as an
* interface. Like an interface the class matches, a view that exposes a
* method needs a live receiver (see protocol). */
private pickOfProgramClass(type: ts.Type): boolean {
const alias = type.getAliasSymbol();
if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit"))
return false;
if (!alias.declarations.every((declaration) => declaration.getSourceFile().isDeclarationFile))
return false;
const source = type.getAliasTypeArguments()[0];
return source !== undefined && this.isProgramClassInstance(source);
}
private isProgramClassInstance(type: ts.Type): boolean {
const symbol = type.getSymbol();
return (
@@ -592,6 +640,8 @@ export class ClassProtocols {
return element !== undefined && this.carriesIdentity(element, marked);
}
if (this.isProgramClassInstance(type)) return true;
// A live Pick or Omit view of a program class holds the instance itself.
if (this.pickOfProgramClass(type) && this.usesCheckedIdentity(type)) return true;
const symbol = type.getSymbol();
if (symbol === undefined) return false;
if (marked.has(symbol)) return true;
@@ -704,12 +754,13 @@ export class ClassProtocols {
const properties = this.checker.getPropertiesOfType(type);
if (symbol && protocols.has(symbol)) return true;
if (
!symbol ||
!symbol.declarations.some(
(declaration) =>
ts.isInterfaceDeclaration(declaration) &&
!ts.getSourceFileOfNode(declaration).isDeclarationFile,
)
!this.pickOfProgramClass(type) &&
(!symbol ||
!symbol.declarations.some(
(declaration) =>
ts.isInterfaceDeclaration(declaration) &&
!ts.getSourceFileOfNode(declaration).isDeclarationFile,
))
)
return false;
for (const property of properties) {
@@ -275,10 +275,13 @@ export function genericCallInstance(
{
const rdecl = rsig ? rsig.declaration : undefined;
if (
rsig &&
rdecl &&
(ts.isFunctionDeclaration(rdecl) || ts.isMethodDeclaration(rdecl)) &&
!rdecl.body
) {
const overload = overloadCallInstance(lowerer, expr, info, rsig, rdecl);
if (overload) return overload;
lowerer.unsupported(
"SC1090",
expr,
@@ -313,14 +316,7 @@ export function genericCallInstance(
fenceGenericSignatureResult(lowerer, expr, retTs);
lowerer.badType(expr, retTs);
}
// A body that can return an indexed read that misses returns undefined
// there, as in JavaScript, whatever the declared result says. An async
// body's promise is always present and settles with undefined there.
const returnType = lowerer.runtimeOptionalGenericReturns.has(info.decl)
? mappedReturn.kind === "promise"
? { kind: "promise" as const, inner: lowerer.runtimeOptionalType(mappedReturn.inner) }
: lowerer.runtimeOptionalType(mappedReturn)
: mappedReturn;
const returnType = runtimeReturnType(lowerer, info, mappedReturn);
// keyof-constrained type parameters (`K extends keyof T`): the bound
// LITERAL is semantic — the instance body reads the named field — so
@@ -331,23 +327,11 @@ export function genericCallInstance(
// share one runtime-keyed instance per IR signature, exactly the
// widened discipline.
const named = staticNameBindings(lowerer, expr, info);
const keyofTps = keyofConstrainedTypeParams(info);
if (keyofTps.size > 0) {
if (keyofConstrainedTypeParams(info).size > 0) {
const tsBindings = new Map<ts.Symbol, ts.Type>();
const bindings = lowerer.inferTypeParamBindings(expr, info, rsig, tsBindings);
const litKey = info.typeParams
.filter((tp) => keyofTps.has(tp))
.map((tp) => {
const bound = tsBindings.get(tp);
return bound?.isStringLiteralType()
? JSON.stringify(bound.value)
: bound?.isNumberLiteralType()
? String(bound.value)
: "*";
})
.join(",");
return internGenericInstance(lowerer, expr, info, params, returnType, () => bindings, {
extraKey: `@${litKey}${named?.key ?? ""}`,
extraKey: `${keyofLiteralKey(info, tsBindings)}${named?.key ?? ""}`,
tsBindings,
...(named ? { staticFnNames: named.names } : {}),
});
@@ -363,6 +347,110 @@ export function genericCallInstance(
);
}
/** A call that resolved to an OVERLOAD signature of a generic function
* or method with an implementation (`request<T>(p: WithHandler<T>):
* Promise<T>; request(p: Plain): Promise<Res>; request<T>(p: Params<T>)
* {...}`): the implementation monomorphizes under the bindings the
* overload's resolved signature proves for its type parameters (the
* structural unification every generic call uses, run against the
* overload; unbound parameters take their declared defaults). The
* instance's parameters and result are the IMPLEMENTATION's under those
* bindings — the body was checked against them — and the call's
* arguments and result coerce across, exactly as a non-generic overload
* call reconciles with its implementation (parameters the overload omits
* must be optional in the implementation). Null when the overload does
* not belong to this implementation or the arities do not line up. */
function overloadCallInstance(
lowerer: Lowerer,
expr: ts.CallExpression,
info: GenericFnInfo,
rsig: ts.Signature,
rdecl: ts.FunctionDeclaration | ts.MethodDeclaration,
): GenericInstance | null {
const impl = info.decl;
if (
rdecl.parent !== impl.parent ||
rdecl.name === undefined ||
impl.name === undefined ||
rdecl.name.getText() !== impl.name.getText() ||
rdecl.parameters.nodes.length > impl.parameters.nodes.length ||
impl.parameters.nodes
.slice(rdecl.parameters.nodes.length)
.some((p) => p.questionToken === undefined && p.initializer === undefined) ||
rdecl.parameters.nodes.some((p) => p.dotDotDotToken !== undefined)
)
return null;
const declSig = lowerer.checker.getSignatureFromDeclaration(impl);
if (!declSig) return null;
const tsBindings = new Map<ts.Symbol, ts.Type>();
const bindings = new Map<ts.Symbol, IrType>();
unifySignatureBindings(lowerer, info, rsig, bindings, tsBindings);
// Explicit type arguments name the OVERLOAD's type parameters, which
// need not match the implementation's in order: the resolved overload
// already carries them, so unification binds first, and an explicit
// argument binds only a still-unbound implementation parameter of the
// same name.
const overloadTps = rdecl.typeParameters?.nodes ?? [];
expr.typeArguments?.nodes.forEach((ta, i) => {
const name = overloadTps[i]?.name.text;
const tp = info.typeParams.find((p) => ts.symbolName(p) === name);
if (!tp || bindings.has(tp)) return;
const taT = lowerer.checker.getTypeFromTypeNode(ta);
const mapped = lowerer.mapTypeOf(taT);
if (mapped) {
bindings.set(tp, mapped);
tsBindings.set(tp, taT);
}
});
bindDefaultTypeParams(lowerer, info.typeParams, impl.typeParameters?.nodes, bindings, tsBindings);
// A type parameter the overload never mentions (a handler the overload
// omits) is `unknown`, the checker's own answer for an uninferred one.
for (const tp of info.typeParams) {
if (bindings.has(tp)) continue;
bindings.set(tp, DYN);
tsBindings.set(tp, lowerer.checker.getUnknownType());
}
const previous = lowerer.typeParamBindings;
const previousTs = lowerer.typeParamTsBindings;
lowerer.typeParamBindings = bindings;
lowerer.typeParamTsBindings = tsBindings;
let params: ParamShape[];
let returnType: IrType | null;
try {
params = paramShapes(
lowerer,
impl.parameters.nodes,
declSig,
(_param, i) => expr.arguments.nodes[i] ?? expr,
);
returnType = lowerer.mapTypeOf(lowerer.checker.getReturnTypeOfSignature(declSig));
} finally {
lowerer.typeParamBindings = previous;
lowerer.typeParamTsBindings = previousTs;
}
if (!returnType) return null;
// keyof-constrained parameters key their instance on the bound literal,
// as on the implementation-signature path.
const runtimeReturn = runtimeReturnType(lowerer, info, returnType);
return internGenericInstance(lowerer, expr, info, params, runtimeReturn, () => bindings, {
...(keyofConstrainedTypeParams(info).size > 0
? { extraKey: keyofLiteralKey(info, tsBindings) }
: {}),
tsBindings,
});
}
/** An instance's result type: a body that can return an indexed read
* that misses returns undefined there, as in JavaScript, whatever the
* declared result says. An async body's promise is always present and
* settles with undefined there. */
function runtimeReturnType(lowerer: Lowerer, info: GenericFnInfo, mapped: IrType): IrType {
if (!lowerer.runtimeOptionalGenericReturns.has(info.decl)) return mapped;
return mapped.kind === "promise"
? { kind: "promise", inner: lowerer.runtimeOptionalType(mapped.inner) }
: lowerer.runtimeOptionalType(mapped);
}
/** A generic factory whose result is one of its own local classes
* (`function base<T>(v: T) { return class { value: T = v }; }`). Each
* instance has its own class, so the instance is keyed on its bindings and
@@ -596,6 +684,25 @@ function keyofConstrainedTypeParams(info: GenericFnInfo): Set<ts.Symbol> {
return out;
}
/** The instance-key suffix of the literals bound to `info`'s
* keyof-constrained type parameters (`@"a"`; `*` for a non-literal
* binding): two literals can map to one IR signature while their bodies
* read different fields. */
function keyofLiteralKey(info: GenericFnInfo, tsBindings: Map<ts.Symbol, ts.Type>): string {
const keyofTps = keyofConstrainedTypeParams(info);
const literals = info.typeParams
.filter((tp) => keyofTps.has(tp))
.map((tp) => {
const bound = tsBindings.get(tp);
return bound?.isStringLiteralType()
? JSON.stringify(bound.value)
: bound?.isNumberLiteralType()
? String(bound.value)
: "*";
});
return `@${literals.join(",")}`;
}
/** Type-parameter symbol → concrete IR type for one instantiation.
* Explicit type arguments bind directly; the rest come from structurally
* matching each DECLARED param/return type (which mentions the type
@@ -736,6 +843,27 @@ function unifySignatureBindings(
// so match it against the whole remaining union. Removing null
// too is safe only when no undeclared null arm remains.
unify(dParts[0]!, lowerer.checker.getNonNullableType(inst), depth + 1);
} else if (dParts.length > 1 && iParts.length === dParts.length) {
// `T | Promise<T>` against `Res | Promise<Res>`: each declared
// part that names a generic target (Promise, an interface, an
// alias) pairs with the one instantiated part of the same target;
// a single remaining bare type parameter takes the single
// remaining instantiated part.
const remaining = [...iParts];
const bare: ts.Type[] = [];
for (const d of dParts) {
if (d.flags & ts.TypeFlags.TypeParameter) {
bare.push(d);
continue;
}
const target = d.getSymbol() ?? d.getAliasSymbol();
if (target === undefined) return;
const matches = remaining.filter((i) => (i.getSymbol() ?? i.getAliasSymbol()) === target);
if (matches.length !== 1) return;
remaining.splice(remaining.indexOf(matches[0]!), 1);
unify(d, matches[0]!, depth + 1);
}
if (bare.length === 1 && remaining.length === 1) unify(bare[0]!, remaining[0]!, depth + 1);
}
return;
}
@@ -3367,6 +3367,8 @@ function lowerDynReceiverMethodCall(
? lowerer.checker.getNonNullableType(rawRecvTs)
: rawRecvTs;
if (lowerer.classImplementedProtocol(recvTs)) {
const viewed = pickViewGenericMethodCall(lowerer, call, access, recvTs);
if (viewed) return viewed;
const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text);
if (
property &&
@@ -10411,6 +10413,53 @@ function maybePromiseHandlerResult(
return stmt;
}
/** `view.m(args)` where `view` is a `Pick<C, K>` / `Omit<C, K>` view of a
* program class C and `m` is one of C's GENERIC methods (its overloads
* included): generic methods have no dispatchable method value, so the
* view's live receiver is extracted as the C instance it holds (a checked
* extraction — a value of another class throws Node-style TypeError
* text naming C) and the call monomorphizes statically against C, the
* same lowering a C-typed receiver gets. Null for other receivers. */
function pickViewGenericMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
recvTs: ts.Type,
): IrExpr | null {
const alias = recvTs.getAliasSymbol();
if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit"))
return null;
if (!alias.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)) return null;
const source = recvTs.getAliasTypeArguments()[0];
const symbol = source?.getSymbol();
const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined;
if (!info || call.questionDotToken || access.questionDotToken) return null;
const found = findGenericMethodOn(lowerer, info, access.name.text);
if (!found) return null;
const loc = locOf(access.expression);
const lowered = lowerer.lowerExpr(access.expression);
const instance: IrType = { kind: "object", className: info.def.name };
// A view that statically holds an instance of another class (a
// structurally compatible one) calls that class's method in Node, not
// C's: the call is refused rather than extracted as a C instance.
if (
lowered.type.kind === "object" &&
lowered.type.className !== info.def.name &&
!lowerer.isSubclassOf(lowered.type.className, info.def.name)
) {
lowerer.unsupported(
"SC1090",
access,
`calling the generic method '${access.name.text}' through a '${ts.symbolName(alias)}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`,
);
}
const receiver: IrExpr =
lowered.type.kind === "object"
? lowered
: { kind: "dynCheck", value: lowerer.coerceToExpected(lowered, DYN), type: instance, loc };
return lowerClassGenericMethodCall(lowerer, call, access, info, found, receiver);
}
/** `Object.assign(err, ...sources)` in TypeScript onto an error-hierarchy
* target (`Object.assign(new Error(message), details)`): the sources'
* own members land on the error — the runtime copy over its
+181 -13
View File
@@ -2138,7 +2138,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
(ts.isInterfaceDeclaration(d) ||
ts.isTypeAliasDeclaration(d) ||
ts.isTypeLiteralNode(d) ||
ts.isMappedTypeNode(d)),
ts.isMappedTypeNode(d) ||
isDeclaredDataClass(d) ||
(decls.some(isDeclaredDataClass) && isTypeOnlyNamespace(d))),
);
const aliasDecls = packageOnly(widened.getAliasSymbol());
const ownDecls = packageOnly(widened.getSymbol());
@@ -6520,6 +6522,48 @@ function isMappedShape(t: ts.Type): boolean {
* of a package the program does not compile. Its pure type shapes —
* interfaces, aliases, type literals — describe data a program can build
* itself (`import type` from a types-only package such as type-fest). */
/** A class an SDK declares only to describe data (aws-sdk's
* `declare class PresignedPost { url: string; fields: Fields }`, which
* arrives as JSON and is never constructed): public instance fields and
* nothing else — no constructor, methods, accessors, index signatures,
* static, private or protected members, base classes, implemented
* interfaces or type parameters. Its instances are exactly the records
* of those fields, so the type maps structurally like an interface. */
function isDeclaredDataClass(d: ts.Node): boolean {
if (!ts.isClassDeclaration(d) || !d.getSourceFile().isDeclarationFile) return false;
if (d.heritageClauses !== undefined && d.heritageClauses.nodes.length > 0) return false;
if (d.typeParameters !== undefined && d.typeParameters.nodes.length > 0) return false;
return d.members.nodes.every(
(member) =>
ts.isPropertyDeclaration(member) &&
!ts.isPrivateIdentifier(member.name) &&
!(member.modifiers?.nodes ?? []).some(
(modifier) =>
modifier.kind === ts.SyntaxKind.StaticKeyword ||
modifier.kind === ts.SyntaxKind.PrivateKeyword ||
modifier.kind === ts.SyntaxKind.ProtectedKeyword ||
modifier.kind === ts.SyntaxKind.AbstractKeyword,
),
);
}
/** A namespace merged into a declared data class that holds only types
* (`namespace PresignedPost { interface Fields { … } }`): it adds no value
* the program could reach. */
function isTypeOnlyNamespace(d: ts.Node): boolean {
if (!ts.isModuleDeclaration(d)) return false;
const body = d.body;
if (body === undefined) return true;
if (ts.isModuleDeclaration(body)) return isTypeOnlyNamespace(body);
if (!ts.isModuleBlock(body)) return false;
return body.statements.nodes.every(
(statement) =>
ts.isInterfaceDeclaration(statement) ||
ts.isTypeAliasDeclaration(statement) ||
isTypeOnlyNamespace(statement),
);
}
function packageTypeFile(sf: ts.SourceFile, ctx: Pick<TypeMapperCtx, "isStdlibFile">): boolean {
return (
sf.isDeclarationFile && /[\\/]node_modules[\\/]/.test(sf.fileName) && !ctx.isStdlibFile(sf)
@@ -7287,10 +7331,11 @@ function mapRecordTypeInner(
let v: IrType | null = null;
for (const info of indexInfos) {
if (!stringKey(info.keyType) && !(info.keyType.flags & ts.TypeFlags.Number)) return null;
const iv = mapType(info.type, ctx);
// JSONValue>` (a package's own JSON alias) and `Record<string, any>`
// An `any`-valued signature (`{ [key: string]: any }`, an SDK's
// `Dictionary<any>`) holds whatever the program stores: its
// overflow values are checked-dynamic, exactly `Record<string,
// unknown>`'s.
const iv = info.type.flags & ts.TypeFlags.Any ? DYN : mapType(info.type, ctx);
if (!iv || !isSupportedIndexValue(iv)) return null;
if (v !== null && !typeEquals(v, iv)) return null;
v = iv;
@@ -7517,6 +7562,22 @@ function mapRecordTypeInner(
pt = arrayOf(unitOnlyUnion(ctx.unions));
}
if (pt === null && (fieldTs.flags & ts.TypeFlags.Any) !== 0) pt = DYN;
// An `any[]` field (`list: any[]` in an SDK's message type, also
// optional or nullable) is the checked-dynamic array, exactly as
// `unknown[]` maps; the checked-dynamic value holds the units too.
// Declared members only: an object literal's inferred `any[]` member
// keeps its existing story.
if (
pt === null &&
declaredMember(p) &&
(fieldTs.isUnionType() ? ts.constituentTypes(fieldTs) : [fieldTs]).every(
(part) =>
isAnyArrayType(part, checker) ||
(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) !== 0,
) &&
!isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam)
)
pt = DYN;
// Unit-only FIELDS (`{ msg?: undefined }` — the discriminated-union
// absent-field idiom — and `{ p: undefined }` spellings): the
// unit-only union. The runtime value is the interned unit, JSON
@@ -7613,6 +7674,10 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean {
return true;
case "record":
case "array":
// `T | Promise<T>` with T bound to `unknown`: the checked-dynamic value
// holds a promise by reference, so `unknown` absorbs the promise arm
// exactly as the checker's own union reduction does.
case "promise":
return canConvertToDyn(
arm,
(id) => ctx.shapes.get(id),
@@ -7907,15 +7972,118 @@ export function describeRecordMemberBlocker(widened: ts.Type, ctx: TypeMapperCtx
if (erasedOverloadMemberType(fieldTs, ctx) !== undefined) continue;
return `the record shape is supported, but its member '${ts.symbolName(p)}' ${describeOverloadSetBlocker(fieldTs, ctx)}`;
}
let pt = isGenericCallableMemberType(fieldTs, checker)
? erasedGenericMemberType(fieldTs, ctx)
: mapType(fieldTs, ctx);
if (pt === undefined) continue;
if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam))
pt = unitOnlyUnion(ctx.unions);
if (!pt || pt.kind === "void") {
return `the record shape is supported, but its member '${ts.symbolName(p)}' has type '${checker.typeToString(fieldTs)}', which does not compile`;
if (memberMaps(fieldTs, ctx, p)) continue;
// Name the deepest member the failure comes from (`order.deployment.
// builds[].config`): a large message type otherwise points only at
// its outermost member.
const inner = innerBlockedMember(fieldTs, ctx, new Set());
if (inner !== null && inner.path !== "") {
return `the record shape is supported, but its member '${ts.symbolName(p)}${inner.path}' has type '${checker.typeToString(inner.type)}', which does not compile`;
}
return `the record shape is supported, but its member '${ts.symbolName(p)}' has type '${checker.typeToString(fieldTs)}', which does not compile`;
}
return null;
}
/** A member an interface, type literal or class declares (not one an
* object literal's inferred type carries). */
function declaredMember(member: ts.Symbol): boolean {
return (
member.declarations.length > 0 &&
member.declarations.every((d) => ts.isPropertySignature(d) || ts.isPropertyDeclaration(d))
);
}
/** Whether a record member of type `fieldTs` maps, by the member rules of
* mapRecordTypeInner (generic members erase; `any` and `any[]` members
* are checked-dynamic; unit-only members are the unit union). Overloaded
* members answer true: describeOverloadSetBlocker tells their story. */
function memberMaps(fieldTs: ts.Type, ctx: TypeMapperCtx, member?: ts.Symbol): boolean {
const { checker } = ctx;
if (isOverloadedCallableMemberType(fieldTs, checker)) return true;
const pt = isGenericCallableMemberType(fieldTs, checker)
? erasedGenericMemberType(fieldTs, ctx)
: mapType(fieldTs, ctx);
if (pt === undefined) return true;
if (pt === null && (fieldTs.flags & ts.TypeFlags.Any) !== 0) return true;
if (
pt === null &&
(member === undefined || declaredMember(member)) &&
(fieldTs.isUnionType() ? ts.constituentTypes(fieldTs) : [fieldTs]).every(
(part) =>
isAnyArrayType(part, checker) ||
(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) !== 0,
) &&
!isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam)
)
return true;
if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam)) return true;
return pt !== null && pt.kind !== "void";
}
/** The member path inside the unmapped type `t` that its failure comes
* from, and that member's type: `.name` steps into record members, `[]`
* into array elements, `()` into a function's return; union arms and
* promise payloads are transparent. The empty path means `t` itself is
* the blocker; null means `t` maps (or is `any`). */
function innerBlockedMember(
t: ts.Type,
ctx: TypeMapperCtx,
seen: Set<ts.Type>,
): { path: string; type: ts.Type } | null {
const { checker } = ctx;
// `any` is no component to name: the member holding it is the blocker.
if ((t.flags & ts.TypeFlags.Any) !== 0 || mapType(t, ctx) !== null) return null;
const here = { path: "", type: t };
if (seen.has(t) || seen.size >= 16) return here;
seen.add(t);
const step = (prefix: string, inner: ts.Type): { path: string; type: ts.Type } => {
const found = innerBlockedMember(inner, ctx, seen);
return found === null ? here : { path: prefix + found.path, type: found.type };
};
if (t.isUnionType()) {
const UNIT = ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null;
for (const part of ts.constituentTypes(t)) {
if ((part.flags & UNIT) !== 0 || mapType(part, ctx) !== null) continue;
return step("", part);
}
return here;
}
if ((t.flags & ts.TypeFlags.Object) === 0) return here;
if (checker.isArrayType(t)) {
const elem = checker.getTypeArguments(t as ts.TypeReference)[0];
return elem === undefined ? here : step("[]", elem);
}
const symbol = t.getSymbol();
if (
symbol !== undefined &&
ts.symbolName(symbol) === "Promise" &&
symbol.declarations.some((d) => ctx.isStdlibFile(d.getSourceFile()))
) {
const payload = checker.getTypeArguments(t as ts.TypeReference)[0];
return payload === undefined ? here : step("", payload);
}
const calls = checker.getCallSignatures(t);
if (calls.length === 1) {
const sig = calls[0]!;
if (sig.getTypeParameters().length > 0) return here;
if (sig.getParameters().some((p) => mapType(checker.getTypeOfSymbol(p), ctx) === null))
return here;
return step("()", checker.getReturnTypeOfSignature(sig));
}
if (
calls.length > 0 ||
checker.getConstructSignatures(t).length > 0 ||
checker.isTupleType(t) ||
checker.getIndexInfosOfType(t).length > 0
)
return here;
for (const p of checker.getPropertiesOfType(t)) {
if ((p.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) !== 0) continue;
if (ts.symbolName(p).startsWith("__@")) continue;
const fieldTs = checker.getTypeOfSymbol(p);
if (memberMaps(fieldTs, ctx, p)) continue;
return step(`.${ts.symbolName(p)}`, fieldTs);
}
return here;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6412-any-valued-dictionaries.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6413-sdk-declared-data-class/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6414-pick-class-method-views.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6495-generic-method-overloads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6496-overload-signatures.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6497-generic-overload-bindings.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/nested-member-blocker-path.ts"
],
"diags": []
}
@@ -1,6 +0,0 @@
{
"order": [
"<repo>/tests/diagnostics/overload-fences.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/pick-view-generic-methods.ts"
],
"diags": []
}
@@ -0,0 +1,50 @@
// Message types whose members are typed with `any`: string-keyed
// dictionaries of `any` values (`{ [key: string]: any }`, a `Dictionary<T =
// any>` alias used bare), and `any[]` lists, also optional, nested in
// arrays of records. Values keep whatever the program or JSON stored.
type Dictionary<T = any> = { [key: string]: T };
interface Build {
use: string;
src?: string;
config?: Dictionary;
}
interface Order {
id: string;
builds?: Build[];
upload: { url: string; fields: { [key: string]: any } };
tags: any[];
extra?: any[];
}
function tags(): Dictionary<any> {
return { a: 1, b: "x", c: [1, 2] };
}
async function meta(): Promise<Dictionary> {
return { n: 3 };
}
async function main(): Promise<void> {
const order: Order = JSON.parse(
'{"id":"o1","builds":[{"use":"node","config":{"zero":true,"n":2}},{"use":"static","src":"a/**"}],' +
'"upload":{"url":"u","fields":{"k":"v","n":2}},"tags":[1,"two",null]}',
);
console.log(order.id, order.upload.url, String(order.upload.fields.k), order.upload.fields["n"] + 1);
for (const build of order.builds ?? []) {
console.log(build.use, build.src ?? "-", JSON.stringify(build.config ?? null));
}
console.log(order.tags.length, order.extra === undefined, JSON.stringify(order.tags));
const t = tags();
for (const k of Object.keys(t)) console.log(k, JSON.stringify(t[k]));
t.d = { deep: true };
console.log(JSON.stringify(t));
const m = await meta();
console.log(m.n * 2, JSON.stringify(m));
const made: Order = { id: "o2", upload: { url: "w", fields: {} }, tags: [], extra: [0] };
made.upload.fields.signature = "s";
made.tags.push({ k: 1 });
console.log(JSON.stringify(made));
}
void main();
@@ -0,0 +1,27 @@
// A types-only SDK package declares the shape of JSON data as a class with
// fields only (`declare class UploadForm { url: string; fields: ... }`,
// with a merged namespace holding the fields interface). The program never
// constructs it: values arrive through JSON.parse or object literals, so
// they compile as records of the declared fields.
import type { UploadForm } from "upload-sdk";
interface Order {
id: string;
upload?: UploadForm;
forms: UploadForm[];
}
function describe(form: UploadForm | undefined): string {
if (!form) return "none";
return `${form.url} ${form.fields.Policy} ${form.fields["X-Signature"]} ${form.fields["key"] ?? "-"} ${Object.keys(form.fields).join(",")}`;
}
const order = JSON.parse(
'{"id":"o1","upload":{"url":"u","fields":{"Policy":"p","X-Signature":"s","key":"k"}},"forms":[]}',
) as Order;
console.log(order.id, describe(order.upload));
const made: UploadForm = { url: "v", fields: { Policy: "q", "X-Signature": "t" } };
order.forms.push(made);
made.fields.extra = "e";
console.log(describe(made), JSON.stringify(order.forms));
console.log(describe(undefined));
@@ -0,0 +1,13 @@
export declare class UploadForm {
/** The URL the form posts to. */
url: string;
/** The hidden form fields. */
fields: UploadForm.Fields;
}
export declare namespace UploadForm {
interface Fields {
Policy: string;
"X-Signature": string;
[key: string]: string;
}
}
@@ -0,0 +1 @@
{ "name": "upload-sdk", "version": "1.0.0", "types": "index.d.ts" }
@@ -0,0 +1,83 @@
// `Pick<C, K>` and `Omit<C, K>` over a class, used as structural parameter
// and field types: a view of the instance's own members. Calls through the
// view run the instance's methods on the instance itself, so its state is
// shared with every other reference.
class ApiClient {
private readonly base: string;
calls = 0;
constructor(base: string) {
this.base = base;
}
request(path: string): Promise<string> {
this.calls++;
return Promise.resolve(this.base + path);
}
close(): void {
console.log("closed after", this.calls);
}
}
interface Context {
client: Pick<ApiClient, "request">;
name: string;
}
async function run(ctx: Context): Promise<void> {
console.log(ctx.name, await ctx.client.request("/x"), await ctx.client.request("/y"));
}
function finish(client: Pick<ApiClient, "request" | "close">): void {
client.close();
}
function count(client: Omit<ApiClient, "close">): number {
return client.calls;
}
const client = new ApiClient("https://h");
void run({ client, name: "ctx" }).then(() => {
finish(client);
console.log(count(client), client.calls);
});
// A live view that flows into another structural type keeps the instance:
// reads through the second view see the instance's later writes.
interface Counter {
calls: number;
}
function counted(view: Pick<ApiClient, "request" | "calls">): Counter {
void view.request("/z");
return view;
}
const second = new ApiClient("https://k");
const counter = counted(second);
second.calls += 10;
const holder: { counter: Counter } = { counter: counted(second) };
second.calls = 100;
console.log("counter", counter.calls, holder.counter.calls, second.calls);
// A view also accepts other classes that have its members. Their instances
// flow through it the same way.
class Meter {
label = "meter";
reads = 0;
read(): number {
return this.reads;
}
}
class Gauge {
reads = 0;
read(): number {
return this.reads;
}
}
interface Reading {
reads: number;
}
function reading(view: Pick<Meter, "read" | "reads">): Reading {
console.log("read", view.read());
return view;
}
const gauge = new Gauge();
const result = reading(gauge);
gauge.reads = 50;
const meter = new Meter();
console.log("reading", result.reads, reading(meter).reads, meter.label);
@@ -0,0 +1,54 @@
// Calls that resolve to OVERLOAD signatures of a generic method or
// function: the implementation monomorphizes under what the overload
// proves for its type parameters (a parameter the overload never mentions
// is unknown), directly and through a `Pick` view of the class, whose
// live receiver keeps the instance's state. `T | Promise<T>` callbacks
// bind T from either arm.
interface Reply {
code: number;
text: string;
}
type Mapper<T> = (reply: Reply) => T | Promise<T>;
class Store {
loads = 0;
async load<T>(key: string, map: Mapper<T>): Promise<T>;
async load(key: string): Promise<Reply>;
async load<T>(key: string, map?: Mapper<T>): Promise<T | Reply> {
this.loads++;
const reply: Reply = { code: 200, text: key };
if (map === undefined) return reply;
return await map(reply);
}
}
interface Services {
store: Pick<Store, "load">;
}
function pick<T>(items: T[], index: number): T;
function pick<T>(items: T[]): T;
function pick<T>(items: T[], index = 0): T {
return items[index]!;
}
async function settle<T>(reply: Reply, map?: Mapper<T>): Promise<T | Reply> {
return map ? await map(reply) : reply;
}
async function main(): Promise<void> {
const store = new Store();
const plain = await store.load("alpha");
console.log(plain.code, plain.text);
const length = await store.load("beta", (reply) => reply.text.length);
console.log(length * 2);
const services: Services = { store };
const viaView = await services.store.load("gamma");
const upper = await services.store.load("delta", async (reply) => reply.text.toUpperCase());
console.log(viaView.text, upper, store.loads);
console.log(pick(["x", "y"]), pick([1, 2, 3], 2));
console.log(JSON.stringify(await settle<Reply>({ code: 1, text: "s" })));
console.log(await settle({ code: 2, text: "tt" }, (reply) => reply.code + reply.text.length));
}
main();
@@ -1,7 +1,7 @@
// The overload fences that REMAIN now that overload signatures lower:
// generic overload calls (the per-instantiation body would be typed
// against a signature it was never checked under), and the multi-
// signature type surfacing where a binding needs it spelled.
// Overload signatures: a generic overload call monomorphizes the
// implementation under the bindings the overload proves (a parameter the
// overload omits is optional in the implementation), and an immutable
// alias of an overloaded declaration projects onto the implementation.
function twice<T>(x: T): T[];
function twice<T>(x: T, n: number): T[];
function twice<T>(x: T, n?: number): T[] {
@@ -0,0 +1,47 @@
// Bindings of generic overload calls: explicit type arguments name the
// overload's type parameters, which can be declared in another order than
// the implementation's, and keyof-constrained parameters bound to
// different literals through an overload read their own fields.
function tag<B, A>(value: A, label: B): string;
function tag<A, B>(value: A, label?: B): string {
return `${typeof value}:${typeof label}:${String(value)}${String(label)}`;
}
console.log(tag<string, number>(5, "x"));
console.log(tag<boolean, string>("y", true));
class Labels {
prefix = "#";
render<B, A>(value: A, label: B): string;
render<A, B>(value: A, label?: B): string {
return `${this.prefix}${typeof value}/${typeof label}`;
}
}
console.log(new Labels().render<number, string>("z", 7));
interface Row {
a: number;
b: number;
name: string;
}
function read<T, K extends keyof T>(row: T, key: K): T[K];
function read<T, K extends keyof T>(row: T, key: K, fallback: T[K]): T[K];
function read<T, K extends keyof T>(row: T, key: K, fallback?: T[K]): T[K] {
const value = row[key];
return value === undefined ? fallback! : value;
}
const row: Row = { a: 1, b: 2, name: "r" };
console.log(read(row, "a"), read(row, "b"), read(row, "name"));
console.log(read(row, "b", 0) + read(row, "a", 0));
// An overload call whose implementation returns an indexed read that
// misses returns undefined, as the implementation's own calls do.
function at<T>(items: T[], index: number): T;
function at<T>(items: T[], index: number, label: string): T;
function at<T>(items: T[], index: number, label?: string): T {
if (label !== undefined) console.log(label);
return items[index];
}
console.log(at([1, 2, 3], 1), at([1, 2, 3], 5));
console.log(at(["a", "b"], 0, "strings"), at(["a", "b"], 9, "strings"));
const missing = at([{ id: 1 }], 3);
console.log(missing === undefined, typeof missing);
@@ -0,0 +1,19 @@
// A record blocked by a member deep inside it names the member's path:
// through a function's result (`()`), the promise and union around it,
// record members (`.name`) and array elements (`[]`).
interface Job {
id: string;
handle: Promise<number> & { cancel(): void };
}
interface Order {
id: string;
jobs: Job[];
}
interface Provider {
name: string;
poll: () => Promise<Order | undefined>;
}
function makeProvider(): Provider {
return { name: "p", poll: async () => undefined };
}
console.log(makeProvider().name);
@@ -0,0 +1,16 @@
// A generic method called through a `Pick` view of a class compiles
// against that class. A view that holds an instance of a structurally
// compatible, unrelated class is refused: Node calls that class's own
// method.
class Store {
load<T>(key: string, map: (text: string) => T): T {
return map(key);
}
}
class Mirror {
load<T>(key: string, map: (text: string) => T): T {
return map(`${key}!`);
}
}
const view: Pick<Store, "load"> = new Mirror();
console.log(view.load("k", (text) => text.length));
@@ -0,0 +1,17 @@
nested-member-blocker-path.ts:16:10 - error SC2009: values of type 'Provider' cannot be compiled: the record shape is supported, but its member 'poll().jobs[].handle' has type 'Promise<number> & { cancel(): void; }', which does not compile
15 | }
16 | function makeProvider(): Provider {
| ^~~~~~~~~~~~
17 | return { name: "p", poll: async () => undefined };
hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')
nested-member-blocker-path.ts:19:13 - error SC2004: uses of 'makeProvider' inherit the blocker on its declaration
18 | }
19 | console.log(makeProvider().name);
| ^~~~~~~~~~~~
20 |
hint: the declaration of 'makeProvider' did not compile — fix the diagnostic reported there and these sites clear with it
@@ -1,6 +0,0 @@
overload-fences.ts:12:13 - error SC1090: calls selecting an overload signature of a generic function (only the implementation signature monomorphizes) is not supported yet
11 | }
12 | console.log(twice("q").length);
| ^~~~~~~~~~
13 | function pick(x: "a"): string;
@@ -0,0 +1,6 @@
pick-view-generic-methods.ts:16:13 - error SC1090: calling the generic method 'load' through a 'Pick' view of 'Store' that holds a 'Mirror' is not supported yet
15 | const view: Pick<Store, "load"> = new Mirror();
16 | console.log(view.load("k", (text) => text.length));
| ^~~~~~~~~
17 |
@@ -0,0 +1 @@
{ "plain": 2783, "san": 2211 }
@@ -0,0 +1 @@
{ "plain": 2032, "san": 1924 }
@@ -0,0 +1 @@
{ "plain": 2061, "san": 1900 }