Skip to content

Improve isDeeplyNestedType for homomorphic mapped types #56169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 23, 2023
Merged
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
84 changes: 48 additions & 36 deletions src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23568,22 +23568,22 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
!hasBaseType(checkClass, getDeclaringClass(p)) : false) ? undefined : checkClass;
}

// Return true if the given type is deeply nested. We consider this to be the case when structural type comparisons
// for maxDepth or more occurrences or instantiations of the same type have been recorded on the given stack. The
// "sameness" of instantiations is determined by the getRecursionIdentity function. An intersection is considered
// deeply nested if any constituent of the intersection is deeply nested. It is possible, though highly unlikely, for
// the deeply nested check to be true in a situation where a chain of instantiations is not infinitely expanding.
// Effectively, we will generate a false positive when two types are structurally equal to at least maxDepth levels,
// but unequal at some level beyond that.
// In addition, this will also detect when an indexed access has been chained off of maxDepth more times (which is
// essentially the dual of the structural comparison), and likewise mark the type as deeply nested, potentially adding
// false positives for finite but deeply expanding indexed accesses (eg, for `Q[P1][P2][P3][P4][P5]`).
// It also detects when a recursive type reference has expanded maxDepth or more times, e.g. if the true branch of
// `type A<T> = null extends T ? [A<NonNullable<T>>] : [T]`
// has expanded into `[A<NonNullable<NonNullable<NonNullable<NonNullable<NonNullable<T>>>>>>]`. In such cases we need
// to terminate the expansion, and we do so here.
// Return true if the given type is deeply nested. We consider this to be the case when the given stack contains
// maxDepth or more occurrences of types with the same recursion identity as the given type. The recursion identity
// provides a shared identity for type instantiations that repeat in some (possibly infinite) pattern. For example,
// in `type Deep<T> = { next: Deep<Deep<T>> }`, repeatedly referencing the `next` property leads to an infinite
// sequence of ever deeper instantiations with the same recursion identity (in this case the symbol associated with
// the object type literal).
// A homomorphic mapped type is considered deeply nested if its target type is deeply nested, and an intersection is
// considered deeply nested if any constituent of the intersection is deeply nested.
// It is possible, though highly unlikely, for the deeply nested check to be true in a situation where a chain of
// instantiations is not infinitely expanding. Effectively, we will generate a false positive when two types are
// structurally equal to at least maxDepth levels, but unequal at some level beyond that.
function isDeeplyNestedType(type: Type, stack: Type[], depth: number, maxDepth = 3): boolean {
if (depth >= maxDepth) {
if ((getObjectFlags(type) & ObjectFlags.InstantiatedMapped) === ObjectFlags.InstantiatedMapped) {
type = getMappedTargetWithSymbol(type);
}
if (type.flags & TypeFlags.Intersection) {
return some((type as IntersectionType).types, t => isDeeplyNestedType(t, stack, depth, maxDepth));
}
Expand All @@ -23592,7 +23592,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
let lastTypeId = 0;
for (let i = 0; i < depth; i++) {
const t = stack[i];
if (t.flags & TypeFlags.Intersection ? some((t as IntersectionType).types, u => getRecursionIdentity(u) === identity) : getRecursionIdentity(t) === identity) {
if (hasMatchingRecursionIdentity(t, identity)) {
// We only count occurrences with a higher type id than the previous occurrence, since higher
// type ids are an indicator of newer instantiations caused by recursion.
if (t.id >= lastTypeId) {
Expand All @@ -23608,6 +23608,32 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
return false;
}

// Unwrap nested homomorphic mapped types and return the deepest target type that has a symbol. This better
// preserves unique type identities for mapped types applied to explicitly written object literals. For example
// in `Mapped<{ x: Mapped<{ x: Mapped<{ x: string }>}>}>`, each of the mapped type applications will have a
// unique recursion identity (that of their target object type literal) and thus avoid appearing deeply nested.
function getMappedTargetWithSymbol(type: Type) {
let target;
while (
(getObjectFlags(type) & ObjectFlags.InstantiatedMapped) === ObjectFlags.InstantiatedMapped &&
(target = getModifiersTypeFromMappedType(type as MappedType)) &&
(target.symbol || target.flags & TypeFlags.Intersection && some((target as IntersectionType).types, t => !!t.symbol))
) {
type = target;
}
return type;
}

function hasMatchingRecursionIdentity(type: Type, identity: object): boolean {
if ((getObjectFlags(type) & ObjectFlags.InstantiatedMapped) === ObjectFlags.InstantiatedMapped) {
type = getMappedTargetWithSymbol(type);
}
if (type.flags & TypeFlags.Intersection) {
return some((type as IntersectionType).types, t => hasMatchingRecursionIdentity(t, identity));
}
return getRecursionIdentity(type) === identity;
}

// The recursion identity of a type is an object identity that is shared among multiple instantiations of the type.
// We track recursion identities in order to identify deeply nested and possibly infinite type instantiations with
// the same origin. For example, when type parameters are in scope in an object type such as { x: T }, all
Expand All @@ -23623,28 +23649,22 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
// unique AST node.
return (type as TypeReference).node!;
}
if (type.symbol) {
// We track object types that have a symbol by that symbol (representing the origin of the type).
if (getObjectFlags(type) & ObjectFlags.Mapped) {
// When a homomorphic mapped type is applied to a type with a symbol, we use the symbol of that
// type as the recursion identity. This is a better strategy than using the symbol of the mapped
// type, which doesn't work well for recursive mapped types.
type = getMappedTargetWithSymbol(type);
}
if (!(getObjectFlags(type) & ObjectFlags.Anonymous && type.symbol.flags & SymbolFlags.Class)) {
// We exclude the static side of a class since it shares its symbol with the instance side.
return type.symbol;
}
if (type.symbol && !(getObjectFlags(type) & ObjectFlags.Anonymous && type.symbol.flags & SymbolFlags.Class)) {
// We track object types that have a symbol by that symbol (representing the origin of the type), but
// exclude the static side of a class since it shares its symbol with the instance side.
return type.symbol;
}
if (isTupleType(type)) {
return type.target;
}
}
if (type.flags & TypeFlags.TypeParameter) {
// We use the symbol of the type parameter such that all "fresh" instantiations of that type parameter
// have the same recursion identity.
return type.symbol;
}
if (type.flags & TypeFlags.IndexedAccess) {
// Identity is the leftmost object type in a chain of indexed accesses, eg, in A[P][Q] it is A
// Identity is the leftmost object type in a chain of indexed accesses, eg, in A[P1][P2][P3] it is A.
do {
type = (type as IndexedAccessType).objectType;
}
Expand All @@ -23658,14 +23678,6 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
return type;
}

function getMappedTargetWithSymbol(type: Type) {
let target = type;
while ((getObjectFlags(target) & ObjectFlags.InstantiatedMapped) === ObjectFlags.InstantiatedMapped && isMappedTypeWithKeyofConstraintDeclaration(target as MappedType)) {
target = getModifiersTypeFromMappedType(target as MappedType);
}
return target.symbol ? target : type;
}

function isPropertyIdenticalTo(sourceProp: Symbol, targetProp: Symbol): boolean {
return compareProperties(sourceProp, targetProp, compareTypesIdentical) !== Ternary.False;
}
Expand Down
117 changes: 116 additions & 1 deletion tests/baselines/reference/deeplyNestedMappedTypes.errors.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ deeplyNestedMappedTypes.ts(9,7): error TS2322: Type 'Id<{ x: { y: { z: { a: { b:
deeplyNestedMappedTypes.ts(17,7): error TS2322: Type 'Id2<{ x: { y: { z: { a: { b: { c: number; }; }; }; }; }; }>' is not assignable to type 'Id2<{ x: { y: { z: { a: { b: { c: string; }; }; }; }; }; }>'.
The types of 'x.y.z.a.b.c' are incompatible between these types.
Type 'number' is not assignable to type 'string'.
deeplyNestedMappedTypes.ts(69,5): error TS2322: Type '{ level1: { level2: { foo: string; }; }; }[]' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }[]'.
Type '{ level1: { level2: { foo: string; }; }; }' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }'.
The types of 'level1.level2' are incompatible between these types.
Property 'bar' is missing in type '{ foo: string; }' but required in type '{ foo: string; bar: string; }'.
deeplyNestedMappedTypes.ts(73,5): error TS2322: Type '{ level1: { level2: { foo: string; }; }; }[]' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type which could be unrelated to '{ level1: { level2: { foo: string; }; }; }[]'.
deeplyNestedMappedTypes.ts(77,5): error TS2322: Type '{ level1: { level2: { foo: string; }; }; }[]' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }[]'.
Type '{ level1: { level2: { foo: string; }; }; }' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }'.
The types of 'level1.level2' are incompatible between these types.
Property 'bar' is missing in type '{ foo: string; }' but required in type '{ foo: string; bar: string; }'.


==== deeplyNestedMappedTypes.ts (2 errors) ====
==== deeplyNestedMappedTypes.ts (5 errors) ====
// Simplified repro from #55535

type Id<T> = { [K in keyof T]: Id<T[K]> };
Expand Down Expand Up @@ -60,4 +70,109 @@ deeplyNestedMappedTypes.ts(17,7): error TS2322: Type 'Id2<{ x: { y: { z: { a: {

declare const bar1: Bar1;
const bar2: Bar2 = bar1; // Error expected

// Repro from #56138

export type Input = Static<typeof Input>
export const Input = Type.Object({
level1: Type.Object({
level2: Type.Object({
foo: Type.String(),
})
})
})

export type Output = Static<typeof Output>
export const Output = Type.Object({
level1: Type.Object({
level2: Type.Object({
foo: Type.String(),
bar: Type.String(),
})
})
})

function problematicFunction1(ors: Input[]): Output[] {
return ors; // Error
~~~~~~
!!! error TS2322: Type '{ level1: { level2: { foo: string; }; }; }[]' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }[]'.
!!! error TS2322: Type '{ level1: { level2: { foo: string; }; }; }' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }'.
!!! error TS2322: The types of 'level1.level2' are incompatible between these types.
!!! error TS2322: Property 'bar' is missing in type '{ foo: string; }' but required in type '{ foo: string; bar: string; }'.
!!! related TS2728 deeplyNestedMappedTypes.ts:63:13: 'bar' is declared here.
}

function problematicFunction2<T extends Output[]>(ors: Input[]): T {
return ors; // Error
~~~~~~
!!! error TS2322: Type '{ level1: { level2: { foo: string; }; }; }[]' is not assignable to type 'T'.
!!! error TS2322: 'T' could be instantiated with an arbitrary type which could be unrelated to '{ level1: { level2: { foo: string; }; }; }[]'.
}

function problematicFunction3(ors: (typeof Input.static)[]): Output[] {
return ors; // Error
~~~~~~
!!! error TS2322: Type '{ level1: { level2: { foo: string; }; }; }[]' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }[]'.
!!! error TS2322: Type '{ level1: { level2: { foo: string; }; }; }' is not assignable to type '{ level1: { level2: { foo: string; bar: string; }; }; }'.
!!! error TS2322: The types of 'level1.level2' are incompatible between these types.
!!! error TS2322: Property 'bar' is missing in type '{ foo: string; }' but required in type '{ foo: string; bar: string; }'.
!!! related TS2728 deeplyNestedMappedTypes.ts:63:13: 'bar' is declared here.
}

export type Evaluate<T> = T extends infer O ? { [K in keyof O]: O[K] } : never

export declare const Readonly: unique symbol;
export declare const Optional: unique symbol;
export declare const Hint: unique symbol;
export declare const Kind: unique symbol;

export interface TKind {
[Kind]: string
}
export interface TSchema extends TKind {
[Readonly]?: string
[Optional]?: string
[Hint]?: string
params: unknown[]
static: unknown
}

export type TReadonlyOptional<T extends TSchema> = TOptional<T> & TReadonly<T>
export type TReadonly<T extends TSchema> = T & { [Readonly]: 'Readonly' }
export type TOptional<T extends TSchema> = T & { [Optional]: 'Optional' }

export interface TString extends TSchema {
[Kind]: 'String';
static: string;
type: 'string';
}

export type ReadonlyOptionalPropertyKeys<T extends TProperties> = { [K in keyof T]: T[K] extends TReadonly<TSchema> ? (T[K] extends TOptional<T[K]> ? K : never) : never }[keyof T]
export type ReadonlyPropertyKeys<T extends TProperties> = { [K in keyof T]: T[K] extends TReadonly<TSchema> ? (T[K] extends TOptional<T[K]> ? never : K) : never }[keyof T]
export type OptionalPropertyKeys<T extends TProperties> = { [K in keyof T]: T[K] extends TOptional<TSchema> ? (T[K] extends TReadonly<T[K]> ? never : K) : never }[keyof T]
export type RequiredPropertyKeys<T extends TProperties> = keyof Omit<T, ReadonlyOptionalPropertyKeys<T> | ReadonlyPropertyKeys<T> | OptionalPropertyKeys<T>>
export type PropertiesReducer<T extends TProperties, R extends Record<keyof any, unknown>> = Evaluate<(
Readonly<Partial<Pick<R, ReadonlyOptionalPropertyKeys<T>>>> &
Readonly<Pick<R, ReadonlyPropertyKeys<T>>> &
Partial<Pick<R, OptionalPropertyKeys<T>>> &
Required<Pick<R, RequiredPropertyKeys<T>>>
)>
export type PropertiesReduce<T extends TProperties, P extends unknown[]> = PropertiesReducer<T, {
[K in keyof T]: Static<T[K], P>
}>
export type TPropertyKey = string | number
export type TProperties = Record<TPropertyKey, TSchema>
export interface TObject<T extends TProperties = TProperties> extends TSchema {
[Kind]: 'Object'
static: PropertiesReduce<T, this['params']>
type: 'object'
properties: T
}

export type Static<T extends TSchema, P extends unknown[] = []> = (T & { params: P; })['static']

declare namespace Type {
function Object<T extends TProperties>(object: T): TObject<T>
function String(): TString
}

Loading