mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-05 08:13:06 +08:00
Distribute conditional members over a generic body's bound parameter
A generic body reading a member typed by a distributive conditional over
its type parameter (`opts?: T extends "alpha" ? AlphaOpts : T extends
"beta" ? BetaOpts : never`) refused it with SC2009 when the instance bound
T to a union of literals (`"alpha" | "gamma"`, `"a" | (string & {})`), and
mapped it as `number | undefined` (never's placeholder) when the binding
selected only never, while the concrete instantiation maps that member as
`null | undefined`; values crossing between the two failed with SC2003.
The conditional now distributes over the binding as the checker does: each
member evaluates, the non-never results form the union, an open `string &
{}` member checks as `string`, and an all-never result leaves an optional
member that holds only undefined.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8a22e0f515
commit
ee63d4a607
@@ -2057,6 +2057,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
return viaConditional;
|
||||
}
|
||||
}
|
||||
const viaDistribution = mapDistributedConditional(type, ctx);
|
||||
if (viaDistribution !== null) {
|
||||
contextResolutions++;
|
||||
return viaDistribution;
|
||||
}
|
||||
}
|
||||
}
|
||||
const widened = checker.getBaseTypeOfLiteralType(type);
|
||||
@@ -5011,11 +5016,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
!checker.isNeverType(part) &&
|
||||
!isEmptyObjectLiteralType(part, ctx),
|
||||
);
|
||||
let neverConditionalPart = false;
|
||||
let undefinedPart: ts.Type | undefined;
|
||||
for (const part of parts) {
|
||||
if (hasArrayPart && emptyTuple(part)) continue;
|
||||
// TypeScript's client can retain impossible intersections in a
|
||||
// distributed union. They have no inhabitants and no runtime tag.
|
||||
if (checker.isNeverType(part)) continue;
|
||||
// A conditional over a bound parameter whose every member selects
|
||||
// `never` contributes no arm either.
|
||||
if (distributedConditionalParts(part, ctx)?.length === 0) {
|
||||
neverConditionalPart = true;
|
||||
continue;
|
||||
}
|
||||
// A `void` PART is inhabited only by undefined (`Promise<void> |
|
||||
// void` return types, `string | void`): it becomes the undefinedT
|
||||
// unit arm, exactly like an undefined part — the value either
|
||||
@@ -5023,6 +5036,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
// void stays VOID (the return-position mapping above).
|
||||
if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) {
|
||||
byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T);
|
||||
undefinedPart ??= part;
|
||||
continue;
|
||||
}
|
||||
if (part.flags & ts.TypeFlags.Null) {
|
||||
@@ -5065,6 +5079,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
}
|
||||
const arms = [...byKey.values()];
|
||||
if (arms.length === 0) return F64; // same unreachable placeholder as standalone never
|
||||
// `Cond<T> | undefined` whose conditional selects never for the
|
||||
// instance's binding is the plain `undefined` the concrete
|
||||
// instantiation's optional member has.
|
||||
if (neverConditionalPart && arms.length === 1 && undefinedPart !== undefined)
|
||||
return mapType(undefinedPart, ctx);
|
||||
// A single surviving UNIT arm cannot stand alone (degenerate — the
|
||||
// checker collapsed everything else away); anything else single is
|
||||
// just that type. A single arm UNDER A MINTED PLACEHOLDER cannot
|
||||
@@ -6136,6 +6155,81 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu
|
||||
return { kind: "union", unionId: ctx.unions.intern(arms) };
|
||||
}
|
||||
|
||||
/** A distributive conditional (`T extends "a" ? A : T extends "b" ? B :
|
||||
* never`, its check type a naked type parameter) over a parameter the
|
||||
* instance binds: evaluated once per member of the binding (`"a" | "b"`,
|
||||
* or the open `"a" | (string & {})` spelling), as the checker distributes
|
||||
* it. Answers the non-never results (empty when every member selects
|
||||
* never, so an optional member of that type holds only undefined, as in
|
||||
* the concrete instantiation), or null when the conditional is another
|
||||
* shape or a member does not evaluate. */
|
||||
function distributedConditionalParts(type: ts.Type, ctx: TypeMapperCtx): ts.Type[] | null {
|
||||
const { checker, resolveTypeParamTs } = ctx;
|
||||
if (!resolveTypeParamTs || !type.isConditionalType()) return null;
|
||||
const checkType = type.getCheckType();
|
||||
if (!checkType || !(checkType.flags & ts.TypeFlags.TypeParameter)) return null;
|
||||
const symbol = checkType.getSymbol();
|
||||
const bound = resolveTypeParamTs(checkType);
|
||||
if (!symbol || !bound) return null;
|
||||
const parts: ts.Type[] = [];
|
||||
for (const member of bound.isUnionType() ? ts.constituentTypes(bound) : [bound]) {
|
||||
// `string & {}` (the open-union spelling) extends exactly what `string`
|
||||
// extends: the empty object adds nothing to a primitive.
|
||||
const emptyObject = (part: ts.Type): boolean =>
|
||||
(part.flags & ts.TypeFlags.Object) !== 0 &&
|
||||
checker.getPropertiesOfType(part).length === 0 &&
|
||||
checker.getIndexInfosOfType(part).length === 0 &&
|
||||
checker.getCallSignatures(part).length === 0 &&
|
||||
checker.getConstructSignatures(part).length === 0;
|
||||
const primitive = member.isIntersectionType()
|
||||
? ts.constituentTypes(member).filter((part) => !emptyObject(part))
|
||||
: [member];
|
||||
if (primitive.length !== 1) return null;
|
||||
const evaluated = evalMappedTemplate(type, new Map(), new Map([[symbol, primitive[0]!]]), ctx);
|
||||
if (!evaluated) return null;
|
||||
if (checker.isNeverType(evaluated)) continue;
|
||||
if (!parts.includes(evaluated)) parts.push(evaluated);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** A unit-only type once its conditionals over bound parameters are
|
||||
* distributed: `Cond<T> | undefined` where every member of T's binding
|
||||
* selects never (the optional member's type in a generic body). */
|
||||
function neverConditionalUnitOnly(t: ts.Type, ctx: TypeMapperCtx): boolean {
|
||||
const UNIT = ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null;
|
||||
if (!t.isUnionType()) return false;
|
||||
let conditional = false;
|
||||
for (const part of ts.constituentTypes(t)) {
|
||||
if ((part.flags & UNIT) !== 0) continue;
|
||||
if (distributedConditionalParts(part, ctx)?.length !== 0) return false;
|
||||
conditional = true;
|
||||
}
|
||||
return conditional;
|
||||
}
|
||||
|
||||
/** mapType of a distributed conditional (distributedConditionalParts): the
|
||||
* union of its members' results. Null when it does not distribute, when a
|
||||
* result does not map, or when every member selects never (the enclosing
|
||||
* union drops such a part). */
|
||||
function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
const parts = distributedConditionalParts(type, ctx);
|
||||
if (!parts || parts.length === 0) return null;
|
||||
const arms: IrType[] = [];
|
||||
for (const part of parts) {
|
||||
const mapped = mapType(part, ctx);
|
||||
if (!mapped) return null;
|
||||
const members =
|
||||
mapped.kind === "union" ? (ctx.unions.get(mapped.unionId)?.arms ?? null) : [mapped];
|
||||
if (!members) return null;
|
||||
for (const arm of members) if (!arms.some((a) => typeEquals(a, arm))) arms.push(arm);
|
||||
}
|
||||
if (arms.length === 1) return arms[0]!;
|
||||
if (!unionContainerArmsOk(arms)) return null;
|
||||
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
|
||||
return { kind: "union", unionId: ctx.unions.intern(arms) };
|
||||
}
|
||||
|
||||
/** `keyof T extends never ? A : B` over a bound record T (the lib's
|
||||
* Object.entries element spelling): decided by whether the binding has any
|
||||
* key. */
|
||||
@@ -8242,7 +8336,10 @@ function mapRecordTypeInner(
|
||||
// absent-field idiom — and `{ p: undefined }` spellings): the
|
||||
// unit-only union. The runtime value is the interned unit, JSON
|
||||
// omits the undefined arm exactly like an omitted optional.
|
||||
if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam))
|
||||
if (
|
||||
pt?.kind === "void" &&
|
||||
(isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam) || neverConditionalUnitOnly(fieldTs, ctx))
|
||||
)
|
||||
pt = unitOnlyUnion(ctx.unions);
|
||||
if (pt !== null && isUnitOnlyIr(pt, ctx) && jsObjectLiteralProperty(p)) pt = DYN;
|
||||
if (pt?.kind === "array" && jsNeverArrayProp(p, fieldTs, checker)) pt = DYN;
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-conditional-member-bindings.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Members typed by a distributive conditional over a generic parameter
|
||||
// (`opts?: T extends "alpha" ? AlphaOpts : ...`), read inside the generic
|
||||
// body: the instance's binding decides the layout, member by member of a
|
||||
// literal union, and an all-never binding leaves an optional member that
|
||||
// holds only undefined, as the concrete instantiation's member does.
|
||||
|
||||
type KnownApi = "alpha" | "beta" | "gamma";
|
||||
type Api = KnownApi | (string & {});
|
||||
|
||||
interface AlphaOpts {
|
||||
fast?: boolean;
|
||||
}
|
||||
interface BetaOpts {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface Base<TApi extends string> {
|
||||
id: string;
|
||||
api: TApi;
|
||||
}
|
||||
|
||||
interface Item<TApi extends Api> extends Base<TApi> {
|
||||
kind?: "item";
|
||||
opts?: TApi extends "alpha" ? AlphaOpts : TApi extends "beta" ? BetaOpts : never;
|
||||
}
|
||||
|
||||
interface Other extends Base<string> {
|
||||
kind: "other";
|
||||
size: number;
|
||||
}
|
||||
|
||||
type AnyItem<TApi extends Api = Api> = Item<TApi> | Other;
|
||||
|
||||
interface Options<TApi extends Api> {
|
||||
name: string;
|
||||
items: readonly AnyItem<TApi>[];
|
||||
}
|
||||
|
||||
interface Built<TApi extends Api> {
|
||||
name: string;
|
||||
all: () => readonly AnyItem<TApi>[];
|
||||
chat: () => Item<TApi>[];
|
||||
add: (item: AnyItem<TApi>) => void;
|
||||
}
|
||||
|
||||
function describe(item: Item<Api>): string {
|
||||
return `${item.id}:${item.api}:${item.opts === undefined ? "-" : JSON.stringify(item.opts)}`;
|
||||
}
|
||||
|
||||
function build<TApi extends Api = Api>(input: Options<TApi>): Built<TApi> {
|
||||
const baseline = input.items;
|
||||
let dynamic: readonly AnyItem<TApi>[] = [];
|
||||
const current = (): readonly AnyItem<TApi>[] => {
|
||||
const merged = [...baseline];
|
||||
for (const item of dynamic) merged.push(item);
|
||||
return merged;
|
||||
};
|
||||
return {
|
||||
name: input.name,
|
||||
all: current,
|
||||
chat: () => current().filter((item): item is Item<TApi> => item.kind !== "other"),
|
||||
add: (item) => {
|
||||
dynamic = [...dynamic, item];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function report<TApi extends Api>(built: Built<TApi>): void {
|
||||
console.log(`${built.name}: ${built.all().length} items, ${built.chat().length} chat`);
|
||||
for (const item of built.chat()) console.log(` ${describe(item)}`);
|
||||
}
|
||||
|
||||
const mixed: Built<"alpha" | "gamma"> = build({
|
||||
name: "mixed",
|
||||
items: [
|
||||
{ id: "a1", api: "alpha", opts: { fast: true } },
|
||||
{ id: "g1", api: "gamma" },
|
||||
],
|
||||
});
|
||||
mixed.add({ id: "a2", api: "alpha" });
|
||||
report(mixed);
|
||||
|
||||
const beta: Built<"beta"> = build({
|
||||
name: "beta",
|
||||
items: [
|
||||
{ id: "b1", api: "beta", opts: { depth: 2 } },
|
||||
{ id: "o1", api: "other", kind: "other", size: 1 },
|
||||
],
|
||||
});
|
||||
beta.add({ id: "o2", api: "other", kind: "other", size: 3 });
|
||||
report(beta);
|
||||
|
||||
const custom = build<string>({ name: "custom", items: [{ id: "c1", api: "custom" }] });
|
||||
custom.add({ id: "c2", api: "custom" });
|
||||
report(custom);
|
||||
|
||||
const open = build({ name: "open", items: [{ id: "x1", api: "external" }] });
|
||||
report(open);
|
||||
const first = open.chat()[0];
|
||||
console.log(first === undefined ? "none" : first.opts === undefined ? "no opts" : "opts");
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 14775, "san": 16991 }
|
||||
Reference in New Issue
Block a user