Compile overloaded methods on interfaces and object types (#1333)

This commit is contained in:
Chris Tate
2026-10-18 17:01:44 -05:00
committed by GitHub
15 changed files with 794 additions and 158 deletions
@@ -503,6 +503,24 @@ export function requireExactArityValue(
? lowerer.mapTypeOf(lowerer.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// An object-literal METHOD has no contextual type of its own: the
// literal's contextual type names the member slot it fills. An
// overloaded member's slot holds its erased function, which spells the
// completed parameters of an implementation like `m(a?: string)`.
if (
contextual === null &&
ts.isMethodDeclaration(blame) &&
blame.parent !== undefined &&
ts.isObjectLiteralExpression(blame.parent) &&
(ts.isIdentifier(blame.name) || ts.isStringLiteral(blame.name))
) {
const literalType = lowerer.checker.getContextualType(blame.parent);
const member = literalType
? lowerer.checker.getPropertyOfType(literalType, blame.name.text)
: undefined;
const slot = member ? lowerer.memberSlotTypeOf(lowerer.checker.getTypeOfSymbol(member)) : null;
if (slot && typeEquals(slot, funcType)) return;
}
// A union-typed slot (`runner || defaultRunner` under a
// `CommandRunner | undefined` context): the value can only inhabit
// the union's one func arm — judge by it.
@@ -61,7 +61,11 @@ import { lowerProtocolMethodCall } from "./class-protocol-dispatch.js";
import { isJsSourceFile, locOf, requireSpecOf } from "../program.js";
import { lowerJsonRequireCall } from "./json-require.js";
import { lowerAssertInstanceCall } from "./assert-instances.js";
import { isGenericCallableMemberType, typeKey } from "../type-mapper.js";
import {
isGenericCallableMemberType,
isOverloadedCallableMemberType,
typeKey,
} from "../type-mapper.js";
import { dynFallbackType, jsFuncNameOf, newFnCtx, staticImportNamespaceType } from "./lowerer.js";
import {
NARROW_FIRST,
@@ -8207,6 +8211,30 @@ function lowerObjectAssignHybrid(lowerer: Lowerer, call: ts.CallExpression): IrE
return { kind: "recordLit", fields, type: mapped, loc };
}
/** The accessed member is an overload set (isOverloadedCallableMemberType),
* also under an optional chain whose access still spells `| undefined`. */
function isOverloadedMemberAccess(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean {
return isOverloadedCallableMemberType(
lowerer.checker.getNonNullableType(lowerer.typeOf(access)),
lowerer.checker,
);
}
/** The result type of the overload a call through an overloaded member
* resolved to: the erased slot's result converts to it. A non-generic
* overload answers through its declaration, because the resolved signature
* of an optional-chain call (`api?.m(x)`) also carries the chain's
* undefined; a generic one answers through its instantiation. */
function resolvedOverloadResultType(lowerer: Lowerer, call: ts.CallExpression): IrType {
const { checker } = lowerer;
const signature = checker.getResolvedSignature(call);
const decl = signature ? checker.signatureDeclaration(signature) : undefined;
const declared = decl ? checker.getSignatureFromDeclaration(decl) : undefined;
const source = declared && declared.getTypeParameters().length === 0 ? declared : signature;
const result = source ? lowerer.mapTypeOf(checker.getReturnTypeOfSignature(source)) : null;
return result ?? lowerer.irTypeOf(call);
}
export function lowerRecordFieldCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -8238,7 +8266,18 @@ export function lowerRecordFieldCall(
const callee = lowerer.lowerUnionProperty(access);
if (callee?.type.kind !== "func") return null;
const args = completeFuncValueArgs(lowerer, call, callee.type, locOf(call));
return { kind: "callValue", callee, args, type: callee.type.ret, loc: locOf(call) };
const value: IrExpr = {
kind: "callValue",
callee,
args,
type: callee.type.ret,
loc: locOf(call),
};
// An overloaded member's erased result converts to the resolved
// overload's type, as on a single record receiver below.
return isOverloadedMemberAccess(lowerer, access)
? lowerer.coerceInto(call, value, resolvedOverloadResultType(lowerer, call))
: value;
}
const target = lowerer.fieldTarget(access);
const loc = locOf(call);
@@ -8313,10 +8352,14 @@ export function lowerRecordFieldCall(
type: callee.type.ret,
loc,
};
// A generic member's field holds its erased instance: the result
// converts to this call's instantiated return type at the call.
if (isGenericCallableMemberType(lowerer.typeOf(access), lowerer.checker))
// A generic member's field holds its erased instance, and an overloaded
// member's field its erased function (the overloads joined): the result
// converts to this call's instantiated or resolved return type.
const memberType = lowerer.typeOf(access);
if (isGenericCallableMemberType(memberType, lowerer.checker))
result = lowerer.coerceInto(call, result, lowerer.irTypeOf(call));
else if (isOverloadedMemberAccess(lowerer, access))
result = lowerer.coerceInto(call, result, resolvedOverloadResultType(lowerer, call));
return init ? { kind: "seqExpr", stmts: [init], result, type: result.type, loc } : result;
}
@@ -231,9 +231,11 @@ import {
containsUnion,
describeComponentBlocker,
describeRecordMemberBlocker,
erasedOverloadMemberType,
formatIrType,
FETCH_AMBIENT_TYPES,
isOsConstantsSymbol,
isOverloadedCallableMemberType,
isUnitOnlyTsType,
isWorkerHandleType,
jsOpenObjectType,
@@ -7778,6 +7780,15 @@ export class Lowerer {
: mapped;
}
/** The runtime type of a record member slot typed `t`: an overloaded
* member's erased function (erasedOverloadMemberType — the type a record
* shape stores for it), otherwise mapTypeOf. */
memberSlotTypeOf(t: ts.Type): IrType | null {
if (isOverloadedCallableMemberType(t, this.checker))
return erasedOverloadMemberType(t, this.typeCtx) ?? null;
return this.mapTypeOf(t);
}
/** The one position where a contextual UNION must not be adopted over the
* expression's own: the LEFT operand of `&&`, `||`, or `??`. Adopting a
* contextual union is normally safe because tsc proved the value
+326 -153
View File
@@ -9,6 +9,7 @@ import {
BOOL,
bytesOf,
canConvertToDyn,
canDynCheckTo,
CHILD_T,
CRYPTOHASH_T,
CRYPTOHMAC_T,
@@ -3914,158 +3915,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (hybrid) return hybrid;
}
const callSigs = checker.getCallSignatures(widened);
if (callSigs.length === 1) {
const sig = callSigs[0]!;
if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null;
// A SYNTHESIZED rest param (tsc's JS inference for a function body
// reading `arguments` — `(...args: any[]) => any` whose args symbol
// has no declaration): the dotDotDot check below can't see it, and a
// fixed-arity func mapping would LIE about the value's calling
// convention. Detected by the param-count mismatch against the
// signature's own declaration, excluding an erased `this` parameter.
{
const sigDecl = checker.signatureDeclaration(sig);
const declParams =
sigDecl !== undefined && ts.isFunctionLike(sigDecl) ? sigDecl.parameters : undefined;
if (
declParams !== undefined &&
runtimeParameterCount(declParams) !== sig.getParameters().length
) {
return null;
}
// tsgo never SYNTHESIZES that rest param into the inferred signature
// (5.9.3 did — the count mismatch above was the whole detector
// there), so the declaration's own body answers directly.
if (sigDecl !== undefined && ts.isFunctionLike(sigDecl) && bodyReadsArguments(sigDecl)) {
return null;
}
}
const params: IrType[] = [];
let typedRest = false;
for (const p of sig.getParameters()) {
const decl = checker.valueDeclarationOf(p);
const rest = decl !== undefined && ts.isParameter(decl) && decl.dotDotDotToken !== undefined;
// arity ABI selected by lambdaSignature. Treating an inferred any[]
// declaration as a typed packed-array closure would bypass its boxed
// spread/error semantics and change coverage classification.
if (rest && decl !== undefined && isJsSourceFile(decl.getSourceFile())) return null;
const optional =
decl !== undefined &&
ts.isParameter(decl) &&
(decl.questionToken !== undefined || decl.initializer !== undefined);
let pt = mapType(checker.getTypeOfSymbol(p), ctx);
if (
decl &&
ts.isParameter(decl) &&
ts.isSetAccessorDeclaration(decl.parent) &&
isJsSourceFile(decl.getSourceFile())
)
pt = DYN;
// An inferred JavaScript binding pattern consumes an iterable; it
// does not require the caller's array to have exactly this length.
const executor = decl && ts.isParameter(decl) ? decl.parent : null;
const construction = executor?.parent;
const constructorSymbol =
construction && ts.isNewExpression(construction)
? checker.getSymbolAtLocation(construction.expression)
: undefined;
const nativeExecutor =
executor &&
(ts.isArrowFunction(executor) || ts.isFunctionExpression(executor)) &&
construction &&
ts.isNewExpression(construction) &&
construction.arguments?.[0] === executor &&
ts.isIdentifier(construction.expression) &&
construction.expression.text === "Promise" &&
constructorSymbol &&
checker
.declarationsOf(constructorSymbol)
.some((site) => ctx.isStdlibFile(site.getSourceFile()));
if (
!nativeExecutor &&
decl &&
ts.isParameter(decl) &&
!decl.dotDotDotToken &&
!ts.isSetAccessorDeclaration(decl.parent) &&
isJsSourceFile(decl.getSourceFile()) &&
!decl.type &&
!/@(?:param|type)\b/.test(
decl.getSourceFile().text.slice(decl.parent?.pos ?? decl.pos, decl.getStart()),
)
)
pt = DYN;
if (pt) pt = jsOpenObjectType(decl, pt, ctx.shapes, ctx.unions);
// Belt and braces for non-strict type worlds: an optional param's
// ABI slot is always the undefined-armed union (strictNullChecks
// already spells it that way; arm it here if the world didn't).
if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn") {
const armed = withUndefinedArm(pt, ctx.unions);
if (!armed) return null;
pt = armed;
}
if (!pt) return null;
if (rest) {
if (pt.kind !== "array") return null;
typedRest = true;
}
// `(value: void) => void` (Promise<void>'s resolve) is callable with
// no arguments — a void param is dropped, not a mapping failure.
if (pt.kind === "void") continue;
params.push(pt);
}
const retT = checker.getReturnTypeOfSignature(sig);
// `() => never` (a throw-only lambda's inferred type) is assignable to
// `() => void` and its calls never produce a value — map the return
// like declaredReturnType does for declarations. JS null/undefined
// returns can read mutable fields, so callable values use the same
// checked-native return ABI as their implementations.
const sigDecl = checker.signatureDeclaration(sig);
const jsUnitReturn =
sigDecl !== undefined &&
isJsSourceFile(sigDecl.getSourceFile()) &&
(retT.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0;
let ret =
jsUnitReturn || (retT.flags & ts.TypeFlags.Any) !== 0
? DYN
: retT.flags & ts.TypeFlags.Never
? VOID
: mapType(retT, ctx);
if (sigDecl && ts.isGetAccessorDeclaration(sigDecl) && isJsSourceFile(sigDecl.getSourceFile()))
ret = DYN;
if (sigDecl && isJsSourceFile(sigDecl.getSourceFile()) && ret?.kind === "array") ret = DYN;
if (
sigDecl &&
ts.isFunctionLike(sigDecl) &&
isJsSourceFile(sigDecl.getSourceFile()) &&
!sigDecl.type &&
!/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) &&
ret?.kind === "record"
)
ret = DYN;
if (
sigDecl &&
ts.isFunctionLike(sigDecl) &&
isJsSourceFile(sigDecl.getSourceFile()) &&
!sigDecl.type &&
!/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) &&
(ret?.kind === "date" ||
(ret?.kind === "union" &&
ctx.unions.get(ret.unionId)?.arms.some((arm) => arm.kind === "func")))
)
ret = DYN;
if (
ret &&
sigDecl &&
isJsSourceFile(sigDecl.getSourceFile()) &&
functionCanReturnUndefined(sigDecl)
)
ret = withUndefinedArm(ret, ctx.unions) ?? ret;
if (!ret) return null;
return typedRest
? { kind: "func", params, ret, rest: true, restAbi: "typed" }
: funcOf(params, ret);
}
if (callSigs.length === 1) return mapCallSignature(callSigs[0]!, ctx);
// 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
@@ -4339,6 +4189,162 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
return null;
}
/** One non-overloaded call signature's func type (mapTypeInner's
* single-signature rule; erasedOverloadMemberType maps each overload of a
* member through it). */
function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null {
const { checker } = ctx;
if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null;
// A SYNTHESIZED rest param (tsc's JS inference for a function body
// reading `arguments` — `(...args: any[]) => any` whose args symbol
// has no declaration): the dotDotDot check below can't see it, and a
// fixed-arity func mapping would LIE about the value's calling
// convention. Detected by the param-count mismatch against the
// signature's own declaration, excluding an erased `this` parameter.
{
const sigDecl = checker.signatureDeclaration(sig);
const declParams =
sigDecl !== undefined && ts.isFunctionLike(sigDecl) ? sigDecl.parameters : undefined;
if (
declParams !== undefined &&
runtimeParameterCount(declParams) !== sig.getParameters().length
) {
return null;
}
// tsgo never SYNTHESIZES that rest param into the inferred signature
// (5.9.3 did — the count mismatch above was the whole detector
// there), so the declaration's own body answers directly.
if (sigDecl !== undefined && ts.isFunctionLike(sigDecl) && bodyReadsArguments(sigDecl)) {
return null;
}
}
const params: IrType[] = [];
let typedRest = false;
for (const p of sig.getParameters()) {
const decl = checker.valueDeclarationOf(p);
const rest = decl !== undefined && ts.isParameter(decl) && decl.dotDotDotToken !== undefined;
// arity ABI selected by lambdaSignature. Treating an inferred any[]
// declaration as a typed packed-array closure would bypass its boxed
// spread/error semantics and change coverage classification.
if (rest && decl !== undefined && isJsSourceFile(decl.getSourceFile())) return null;
const optional =
decl !== undefined &&
ts.isParameter(decl) &&
(decl.questionToken !== undefined || decl.initializer !== undefined);
let pt = mapType(checker.getTypeOfSymbol(p), ctx);
if (
decl &&
ts.isParameter(decl) &&
ts.isSetAccessorDeclaration(decl.parent) &&
isJsSourceFile(decl.getSourceFile())
)
pt = DYN;
// An inferred JavaScript binding pattern consumes an iterable; it
// does not require the caller's array to have exactly this length.
const executor = decl && ts.isParameter(decl) ? decl.parent : null;
const construction = executor?.parent;
const constructorSymbol =
construction && ts.isNewExpression(construction)
? checker.getSymbolAtLocation(construction.expression)
: undefined;
const nativeExecutor =
executor &&
(ts.isArrowFunction(executor) || ts.isFunctionExpression(executor)) &&
construction &&
ts.isNewExpression(construction) &&
construction.arguments?.[0] === executor &&
ts.isIdentifier(construction.expression) &&
construction.expression.text === "Promise" &&
constructorSymbol &&
checker
.declarationsOf(constructorSymbol)
.some((site) => ctx.isStdlibFile(site.getSourceFile()));
if (
!nativeExecutor &&
decl &&
ts.isParameter(decl) &&
!decl.dotDotDotToken &&
!ts.isSetAccessorDeclaration(decl.parent) &&
isJsSourceFile(decl.getSourceFile()) &&
!decl.type &&
!/@(?:param|type)\b/.test(
decl.getSourceFile().text.slice(decl.parent?.pos ?? decl.pos, decl.getStart()),
)
)
pt = DYN;
if (pt) pt = jsOpenObjectType(decl, pt, ctx.shapes, ctx.unions);
// Belt and braces for non-strict type worlds: an optional param's
// ABI slot is always the undefined-armed union (strictNullChecks
// already spells it that way; arm it here if the world didn't).
if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn") {
const armed = withUndefinedArm(pt, ctx.unions);
if (!armed) return null;
pt = armed;
}
if (!pt) return null;
if (rest) {
if (pt.kind !== "array") return null;
typedRest = true;
}
// `(value: void) => void` (Promise<void>'s resolve) is callable with
// no arguments — a void param is dropped, not a mapping failure.
if (pt.kind === "void") continue;
params.push(pt);
}
const retT = checker.getReturnTypeOfSignature(sig);
// `() => never` (a throw-only lambda's inferred type) is assignable to
// `() => void` and its calls never produce a value — map the return
// like declaredReturnType does for declarations. JS null/undefined
// returns can read mutable fields, so callable values use the same
// checked-native return ABI as their implementations.
const sigDecl = checker.signatureDeclaration(sig);
const jsUnitReturn =
sigDecl !== undefined &&
isJsSourceFile(sigDecl.getSourceFile()) &&
(retT.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0;
let ret =
jsUnitReturn || (retT.flags & ts.TypeFlags.Any) !== 0
? DYN
: retT.flags & ts.TypeFlags.Never
? VOID
: mapType(retT, ctx);
if (sigDecl && ts.isGetAccessorDeclaration(sigDecl) && isJsSourceFile(sigDecl.getSourceFile()))
ret = DYN;
if (sigDecl && isJsSourceFile(sigDecl.getSourceFile()) && ret?.kind === "array") ret = DYN;
if (
sigDecl &&
ts.isFunctionLike(sigDecl) &&
isJsSourceFile(sigDecl.getSourceFile()) &&
!sigDecl.type &&
!/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) &&
ret?.kind === "record"
)
ret = DYN;
if (
sigDecl &&
ts.isFunctionLike(sigDecl) &&
isJsSourceFile(sigDecl.getSourceFile()) &&
!sigDecl.type &&
!/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) &&
(ret?.kind === "date" ||
(ret?.kind === "union" &&
ctx.unions.get(ret.unionId)?.arms.some((arm) => arm.kind === "func")))
)
ret = DYN;
if (
ret &&
sigDecl &&
isJsSourceFile(sigDecl.getSourceFile()) &&
functionCanReturnUndefined(sigDecl)
)
ret = withUndefinedArm(ret, ctx.unions) ?? ret;
if (!ret) return null;
return typedRest
? { kind: "func", params, ret, rest: true, restAbi: "typed" }
: funcOf(params, ret);
}
/** Retain a literal discriminator before widening erases its values. A
* structural match alone is insufficient: `{ kind: "loop", body: ... }`
* also fits a smaller `{ kind: "empty" }` record. Choosing that layout
@@ -5585,6 +5591,164 @@ export function erasedGenericMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType
return erased?.kind === "func" ? erased : undefined;
}
/** An OVERLOADED callable member type (`on(event: "a", h: (x: number) =>
* void): () => void; on(event: "b", h: (x: string) => void): () => void`
* in a program interface or type literal): two or more call signatures and
* nothing else (no data properties, construct signatures, or index
* signatures), every one declared in TypeScript program source. Such a
* member keeps a record slot holding its erased runtime function
* (erasedOverloadMemberType); calls convert per their resolved overload
* (lowerRecordFieldCall). */
export function isOverloadedCallableMemberType(t: ts.Type, checker: ts.TypeChecker): boolean {
const sigs = checker.getCallSignatures(t);
if (sigs.length < 2) return false;
if (
checker.getConstructSignatures(t).length > 0 ||
checker.getPropertiesOfType(t).length > 0 ||
checker.getIndexInfosOfType(t).length > 0
)
return false;
return sigs.every((sig) => {
const decl = checker.signatureDeclaration(sig);
if (decl === undefined) return false;
if (!ts.isMethodSignatureDeclaration(decl) && !ts.isCallSignatureDeclaration(decl))
return false;
const file = decl.getSourceFile();
return !file.isDeclarationFile && !isJsSourceFile(file);
});
}
/** Scalar arms an overload join may combine into one union. */
const OVERLOAD_JOIN_SCALARS = new Set(["f64", "string", "bool", "undefinedT", "nullT"]);
/** The join of one position's types across an overload set: equal types
* stay; scalar mixes form their union; anything else is `unknown` when
* every joined type crosses the checked-dynamic boundary in both directions
* (the call site converts into the slot, the implementation converts out
* of it). Differing callbacks (`(x: number) => void` beside `(x: string) =>
* void`) therefore travel as checked function values, which any
* implementation's own callback type adapts from. Null when no join
* exists. */
function joinOverloadTypes(types: readonly IrType[], ctx: TypeMapperCtx): IrType | null {
const first = types[0]!;
if (types.every((t) => typeEquals(t, first))) return first;
const getRecord = (id: string) => ctx.shapes.get(id);
const getUnion = (id: string) => ctx.unions.get(id);
const arms = new Map<string, IrType>();
let scalar = true;
for (const t of types) {
const parts = t.kind === "union" ? (ctx.unions.get(t.unionId)?.arms ?? []) : [t];
if (parts.length === 0) scalar = false;
for (const part of parts) {
if (!OVERLOAD_JOIN_SCALARS.has(part.kind)) scalar = false;
arms.set(typeKey(part), part);
}
}
if (scalar && [...arms.values()].some((arm) => !isUnitType(arm))) {
const sorted = [...arms.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
return { kind: "union", unionId: ctx.unions.intern(sorted) };
}
if (
types.every(
(t) =>
t.kind === "dyn" ||
(t.kind !== "void" &&
canConvertToDyn(t, getRecord, getUnion) &&
canDynCheckTo(t, getRecord, getUnion)),
)
)
return DYN;
return null;
}
/** An overloaded member's runtime type (isOverloadedCallableMemberType).
* JavaScript stores ONE function under the member; overload signatures only
* constrain call sites. The slot's type joins the overloads per parameter
* position and result (joinOverloadTypes), and a position some overloads
* omit is optional. Each overload's own type parameters erase to `unknown`
* first. Calls pick their overload statically, convert arguments into the
* slot's parameter types and the result back to the overload's; an
* implementation converts into the slot like any function value. Undefined
* when the set does not join (rest parameters, a void result beside a
* value result, a component outside the checked-dynamic boundary): the
* member keeps its fence. */
export function erasedOverloadMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType | undefined {
const funcs: (IrType & { kind: "func" })[] = [];
for (const sig of ctx.checker.getCallSignatures(t)) {
const own = new Set(sig.getTypeParameters());
const outer = ctx.resolveTypeParam;
const mapped = mapCallSignature(
sig,
own.size === 0
? ctx
: {
...ctx,
erasingSignature: sig,
resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)),
},
);
if (mapped?.kind !== "func" || mapped.rest === true || mapped.argumentsAll === true)
return undefined;
funcs.push(mapped);
}
const arity = Math.max(...funcs.map((f) => f.params.length));
const params: IrType[] = [];
for (let i = 0; i < arity; i++) {
const present = funcs.filter((f) => i < f.params.length).map((f) => f.params[i]!);
let param = joinOverloadTypes(present, ctx);
if (param !== null && present.length < funcs.length && param.kind !== "dyn")
param = withUndefinedArm(param, ctx.unions);
if (param === null) return undefined;
params.push(param);
}
const voids = funcs.filter((f) => f.ret.kind === "void").length;
if (voids !== 0 && voids !== funcs.length) return undefined;
const ret =
voids === funcs.length
? VOID
: joinOverloadTypes(
funcs.map((f) => f.ret),
ctx,
);
if (ret === null) return undefined;
return funcOf(params, ret);
}
/** Why an overloaded member's set has no erased type
* (erasedOverloadMemberType): the first overload, and within it the first
* parameter or result, that does not map; otherwise the join itself. A
* message tail after "its member 'name'". */
function describeOverloadSetBlocker(t: ts.Type, ctx: TypeMapperCtx): string {
const { checker } = ctx;
for (const sig of checker.getCallSignatures(t)) {
const own = new Set(sig.getTypeParameters());
const outer = ctx.resolveTypeParam;
const sigCtx: TypeMapperCtx =
own.size === 0
? ctx
: {
...ctx,
erasingSignature: sig,
resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)),
};
const mapped = mapCallSignature(sig, sigCtx);
if (mapped?.kind === "func" && mapped.rest !== true && mapped.argumentsAll !== true) continue;
const decl = checker.signatureDeclaration(sig);
const spelled = decl ? decl.getText().replace(/\s+/g, " ").replace(/;$/, "") : "";
const which = spelled ? `overload '${spelled}'` : "one overload";
if (mapped?.kind === "func")
return `is an overload set, and its ${which} has a rest parameter (an overload set stores one fixed-arity function)`;
for (const param of sig.getParameters()) {
const paramTs = checker.getTypeOfSymbol(param);
if (!mapType(paramTs, sigCtx))
return `is an overload set, and its ${which} has parameter '${param.name}' of type '${checker.typeToString(paramTs)}', which does not compile`;
}
const retTs = checker.getReturnTypeOfSignature(sig);
return `is an overload set, and its ${which} returns '${checker.typeToString(retTs)}', which does not compile`;
}
return "is an overload set whose signatures do not join into one stored function (a void result beside a value result, or positions whose differing types do not cross the checked-dynamic boundary)";
}
/** The computed pieces of a record shape mapRecordTypeInner hands back for
* the OUTER frame to intern — or, when a back-reference minted a recursive
* placeholder for the type mid-construction, to finalize INTO that
@@ -5966,9 +6130,14 @@ function mapRecordTypeInner(
// can hold them — see isGenericCallableMemberType): the shape keeps
// its data fields, and calls of the member monomorphize per call
// site against the defining object literal's declaration.
// OVERLOADED members hold their erased runtime function (see
// erasedOverloadMemberType); a set that does not join fails the
// shape as before.
let pt = isGenericCallableMemberType(fieldTs, checker)
? erasedGenericMemberType(fieldTs, ctx)
: mapType(fieldTs, ctx);
: isOverloadedCallableMemberType(fieldTs, checker)
? (erasedOverloadMemberType(fieldTs, ctx) ?? null)
: mapType(fieldTs, ctx);
if (pt === undefined) continue;
// tsgo PANICS computing `readonly []` through the symbol-type query
// (the TupleType conversion — the facade's panic fence answers
@@ -6289,6 +6458,10 @@ export function describeRecordMemberBlocker(widened: ts.Type, ctx: TypeMapperCtx
return null;
}
const fieldTs = checker.getTypeOfSymbol(p);
if (isOverloadedCallableMemberType(fieldTs, checker)) {
if (erasedOverloadMemberType(fieldTs, ctx) !== undefined) continue;
return `the record shape is supported, but its member '${p.name}' ${describeOverloadSetBlocker(fieldTs, ctx)}`;
}
let pt = isGenericCallableMemberType(fieldTs, checker)
? erasedGenericMemberType(fieldTs, ctx)
: mapType(fieldTs, ctx);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6180-interface-overload-events.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6181-interface-overload-arity.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6182-interface-overload-types.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6183-interface-overload-flow.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/overloaded-members.ts"
],
"diags": []
}
@@ -0,0 +1,95 @@
// An interface method with literal-discriminated overloads (the event
// registry shape: on("x", (e: X) => R)) holds ONE function at runtime, as
// JavaScript does. Each call site resolves its overload statically; handlers
// whose types differ per overload cross into the stored function as checked
// function values, and the implementation receives them in its own
// callback type. Events keep their identity across that boundary.
export {};
interface SaveEvent {
type: "save";
path: string;
cancel: boolean;
}
interface LoadEvent {
type: "load";
path: string;
}
interface Context {
cwd: string;
}
interface Api {
on(event: "save", handler: (e: SaveEvent, ctx: Context) => void): () => void;
on(event: "load", handler: (e: LoadEvent, ctx: Context) => boolean): () => void;
on(event: "tick", handler: (n: number) => void): () => void;
name: string;
}
let nextId = 0;
const saves: { id: number; h: (e: SaveEvent, ctx: Context) => void }[] = [];
const loads: { id: number; h: (e: LoadEvent, ctx: Context) => boolean }[] = [];
const ticks: { id: number; h: (n: number) => void }[] = [];
function createApi(name: string): Api {
return {
name,
on(event: "save" | "load" | "tick", handler: any): () => void {
const id = ++nextId;
const count =
event === "save"
? saves.push({ id, h: handler })
: event === "load"
? loads.push({ id, h: handler })
: ticks.push({ id, h: handler });
console.log(`${name}: registered ${event} #${id} (${count})`);
return () => {
if (event === "save") saves.splice(saves.findIndex((x) => x.id === id), 1);
else if (event === "load") loads.splice(loads.findIndex((x) => x.id === id), 1);
else ticks.splice(ticks.findIndex((x) => x.id === id), 1);
console.log(`${name}: removed ${event} #${id}`);
};
},
};
}
// An extension sees only the interface.
function extension(pi: Api): () => void {
pi.on("save", (e, ctx) => {
e.cancel = e.path.endsWith(".lock");
console.log(`save ${ctx.cwd}/${e.path}`);
});
pi.on("load", (e) => e.path.length > 3);
return pi.on("tick", (n) => console.log("tick", n * 2, pi.name));
}
const api = createApi("main");
const stopTicks = extension(api);
const ctx: Context = { cwd: "/work" };
for (const path of ["a.txt", "b.lock"]) {
const ev: SaveEvent = { type: "save", path, cancel: false };
for (const { h } of saves) h(ev, ctx);
console.log(path, "cancelled:", ev.cancel);
}
for (const { h } of loads) console.log(h({ type: "load", path: "ab" }, ctx), h({ type: "load", path: "abcd" }, ctx));
for (const { h } of ticks) h(21);
stopTicks();
console.log("ticks left", ticks.length);
// A named handler value converts the same way as a lambda.
function onTick(n: number): void {
console.log("named tick", n);
}
const off = api.on("tick", onTick);
for (const { h } of ticks) h(7);
off();
// A handler that throws propagates through the boundary unchanged.
api.on("load", (e) => {
if (e.path === "") throw new Error("empty path");
return true;
});
try {
for (const { h } of loads) h({ type: "load", path: "" }, ctx);
} catch (err) {
console.log("caught", (err as Error).message);
}
@@ -0,0 +1,56 @@
// Arity overloads on interface and type-literal members: positions some
// overloads omit are optional in the stored function, so one implementation
// with optional parameters serves every overload, and a call omitting a
// trailing argument passes undefined exactly as Node does.
export {};
interface Formatter {
label(): string;
label(prefix: string): string;
label(prefix: string, width: number): string;
}
const formatter: Formatter = {
label(prefix?: string, width?: number): string {
const text = prefix ?? "none";
return width === undefined ? text : text.padEnd(width, ".") + "|";
},
};
console.log(formatter.label());
console.log(formatter.label("p"));
console.log(formatter.label("q", 6));
// Overloads spelled as call signatures of a type-literal member.
type Logger = {
log: {
(message: string): void;
(level: number, message: string): void;
};
lines: string[];
};
const lines: string[] = [];
const logger: Logger = {
lines,
log: (a: string | number, b?: string) => {
lines.push(typeof a === "number" ? `[${a}] ${b}` : a);
},
};
logger.log("plain");
logger.log(2, "leveled");
console.log(logger.lines.join("; "));
// A single-signature method with an optional parameter fills its slot the
// same way.
interface Greeter {
greet(name?: string): string;
}
const greeter: Greeter = {
greet(name?: string): string {
return `hello ${name ?? "stranger"}`;
},
};
console.log(greeter.greet(), greeter.greet("ada"));
// Arguments are evaluated once, left to right, before the call.
let n = 0;
const next = (): string => `#${++n}`;
console.log(formatter.label(next(), 4 + n), n);
@@ -0,0 +1,61 @@
// Type overloads with one shared implementation: the stored function takes
// the join of the overload parameter types (a scalar union here) and each
// call converts its result back to the resolved overload's type. Records,
// generic overloads, and implementations typed with unions or `any` share
// the same slot.
export {};
interface Converter {
conv(x: number): string;
conv(x: string): number;
conv(x: boolean): boolean;
}
const converter: Converter = {
conv(x: number | string | boolean): any {
return typeof x === "number" ? `n${x}` : typeof x === "string" ? x.length : !x;
},
};
const s: string = converter.conv(12);
const len: number = converter.conv("abcd");
const flipped: boolean = converter.conv(false);
console.log(s.toUpperCase(), len + 1, flipped);
interface Point {
x: number;
y: number;
}
interface Named {
name: string;
}
interface Factory {
make(kind: "point", a: number, b: number): Point;
make(kind: "named", a: string): Named;
}
const factory: Factory = {
make(kind: string, a: number | string, b?: number): any {
return kind === "point" ? { x: a, y: b } : { name: a };
},
};
const p = factory.make("point", 3, 4);
const named = factory.make("named", "zed");
console.log(Math.hypot(p.x, p.y), named.name.length, p, named);
// Generic overloads erase their own type parameters to `unknown`.
interface Store {
get(key: "names", fallback: string[]): string[];
get(key: "count"): number;
at<T>(items: T[], i: number): T;
at(items: string, i: number): string;
}
const data: Record<string, unknown> = { names: ["a", "b"], count: 3 };
const store: Store = {
get(key: string, fallback?: unknown): any {
return data[key] ?? fallback;
},
at(items: any, i: number): any {
return items[i];
},
};
console.log(store.get("names", []).join("|"), store.get("count") * 2);
console.log(store.at([10, 20, 30], 1) + 1, store.at("xyz", 2).toUpperCase());
@@ -0,0 +1,89 @@
// Values of an interface with an overloaded member flow like any record:
// through arrays, factory callbacks, and function results. A class that
// implements the interface dispatches to its own overloaded method.
export {};
interface Bus {
emit(kind: "num", value: number): void;
emit(kind: "text", value: string): void;
label: string;
}
type Plugin = (bus: Bus) => void;
const log: string[] = [];
function makeBus(label: string): Bus {
return {
label,
emit(kind: "num" | "text", value: number | string): void {
log.push(`${label}:${kind}:${typeof value}:${value}`);
},
};
}
const plugins: Plugin[] = [
(bus) => bus.emit("num", 1),
(bus) => {
bus.emit("text", "two");
bus.emit("num", bus.label.length + 2);
},
];
const buses: Bus[] = [makeBus("x"), makeBus("yy")];
for (const bus of buses) for (const plugin of plugins) plugin(bus);
console.log(log.join("\n"));
console.log(typeof buses[1]!.emit, JSON.stringify(buses[0]));
interface Picker {
pick(kind: "s"): string;
pick(kind: "n"): number;
}
class Impl implements Picker {
private readonly base: number;
constructor(base: number) {
this.base = base;
}
pick(kind: "s"): string;
pick(kind: "n"): number;
pick(kind: "s" | "n"): string | number {
return kind === "s" ? `str${this.base}` : this.base * 7;
}
}
function use(p: Picker): string {
return `${p.pick("s").length}/${p.pick("n") + 1}`;
}
console.log(use(new Impl(1)), use(new Impl(10)));
const literal: Picker = {
pick(kind: "s" | "n"): any {
return kind === "s" ? "literal" : -1;
},
};
console.log(use(literal));
// Receivers of every shape resolve the overload the same way: a union of
// records sharing the member, an optional chain, and a call result.
interface Measure {
kind: "len";
of(x: number): string;
of(x: string): number;
}
interface Sign {
kind: "sign";
of(x: number): string;
of(x: string): number;
}
const measure: Measure = {
kind: "len",
of(x: number | string): any {
return typeof x === "number" ? `#${x}` : x.length;
},
};
const sign: Sign = {
kind: "sign",
of(x: number | string): any {
return typeof x === "number" ? (x < 0 ? "-" : "+") : -x.length;
},
};
for (const m of [measure, sign] as (Measure | Sign)[]) console.log(m.kind, m.of(-4).length, m.of("xyz") + 1);
const maybe: Measure | undefined = buses.length > 5 ? undefined : measure;
const none: Measure | undefined = buses.length > 5 ? measure : undefined;
console.log(maybe?.of(5), maybe?.of("abc"), none?.of(1), none?.of("q"));
const pickMeasure = (): Measure => measure;
console.log(pickMeasure().of(7).toUpperCase());
+25
View File
@@ -0,0 +1,25 @@
// Overloaded interface members compile as one stored function whose type
// joins the overloads; calls resolve per site. Two fences remain: reading
// the member as a VALUE (its type is still the overload set), and a set
// whose overloads do not join (here, a rest parameter beside a fixed one).
interface Api {
on(event: "a", handler: (x: number) => void): void;
on(event: "b", handler: (x: string) => void): void;
}
const api: Api = {
on(event: string, handler: (x: any) => void): void {
handler(event === "a" ? 1 : "one");
},
};
api.on("a", (x) => console.log(x + 1));
const detached = api.on;
console.log(typeof detached);
interface Emitter {
emit(event: "many", ...values: number[]): void;
emit(event: "one", value: string): void;
}
function fire(e: Emitter): void {
e.emit("many", 1, 2);
}
console.log(typeof fire);
@@ -0,0 +1,35 @@
overloaded-members.ts:15:7 - error SC2007: values of type '{ (event: "a", handler: (x: number) => void): void; (event: "b", handler: (x: string) => void): void; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature
14 | api.on("a", (x) => console.log(x + 1));
15 | const detached = api.on;
| ^~~~~~~~
16 | console.log(typeof detached);
hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow
overloaded-members.ts:16:20 - error SC2004: uses of 'detached' inherit the blocker on its declaration
15 | const detached = api.on;
16 | console.log(typeof detached);
| ^~~~~~~~
17 |
hint: the declaration of 'detached' did not compile — fix the diagnostic reported there and these sites clear with it
overloaded-members.ts:22:15 - error SC2009: values of type 'Emitter' cannot be compiled: the record shape is supported, but its member 'emit' is an overload set, and its overload 'emit(event: "many", ...values: number[]): void' has a rest parameter (an overload set stores one fixed-arity function)
21 | }
22 | function fire(e: Emitter): void {
| ^
23 | e.emit("many", 1, 2);
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')
overloaded-members.ts:25:20 - error SC2004: uses of 'fire' inherit the blocker on its declaration
24 | }
25 | console.log(typeof fire);
| ^~~~
26 |
hint: the declaration of 'fire' did not compile — fix the diagnostic reported there and these sites clear with it