Skip to content
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

Recursive conditional types are aliased #22575

Open
lstkz opened this issue Mar 14, 2018 · 6 comments
Open

Recursive conditional types are aliased #22575

lstkz opened this issue Mar 14, 2018 · 6 comments
Labels
Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Milestone

Comments

@lstkz
Copy link

lstkz commented Mar 14, 2018

I am trying to create a conditional type that converts

type Before = {
  a: string;
  b: number;
  c: string | undefined;
  d: number | undefined;
  nested: {
    a2: string;
    b2: number;
    c2: string | undefined;
    d2: number | undefined;
    nested2: {
      a3: string;
      b3: number;
      c3: string | undefined;
      d3: number | undefined;
    };
  };
};

to

type After = {
  a: string;
  b: number;
  c?: string | undefined;
  d?: number | undefined;
  nested: {
    a2: string;
    b2: number;
    c2?: string | undefined;
    d2?: number | undefined;
    nested2: {
      a3: string;
      b3: number;
      c3?: string | undefined;
      d3?: number | undefined;
    };
  };
};

When I hover on After in the below code

const fnBefore = (input: Before) => {
  return input;
};

const fnAfter = (input: After) => {
  return input;
};

it shows

type After = {
    c?: string | undefined;
    d?: number | undefined;
    a: string;
    b: number;
    nested: Flatten<{
        c2?: string | undefined;
        d2?: number | undefined;
    } & {
        a2: string;
        b2: number;
        nested2: Flatten<{
            c3?: string | undefined;
            d3?: number | undefined;
        } & RequiredProps>;
    }>;
}

instead of properly converted type.

According to #22011

If an conditional type is instantiated over 100 times, we consider that to be too deep.
At that point, we try to find the respective alias type that contains that conditional type.

only more complex types should be aliased, but I always face this issue.
Also for simple types:

type Simple = {
  nested: {
    a2: string;
    c2: string | undefined;
  };
};

TypeScript Version: 2.8.0-dev.201180314

Full Code

type Before = {
  a: string;
  b: number;
  c: string | undefined;
  d: number | undefined;
  nested: {
    a2: string;
    b2: number;
    c2: string | undefined;
    d2: number | undefined;
    nested2: {
      a3: string;
      b3: number;
      c3: string | undefined;
      d3: number | undefined;
    };
  };
};

type Simple = {
  nested: {
    a2: string;
    c2: string | undefined;
  };
};

type Flatten<T> = { [K in keyof T]: T[K] };

type OptionalPropNames<T> = { [P in keyof T]: undefined extends T[P] ? P : never }[keyof T];
type RequiredPropNames<T> = { [P in keyof T]: undefined extends T[P] ? never : P }[keyof T];

type OptionalProps<T> = { [P in OptionalPropNames<T>]: T[P] };
type RequiredProps<T> = { [P in RequiredPropNames<T>]: T[P] };

type MakeOptional<T> = { [P in keyof T]?: T[P] };

type ConvertObject<T> = Flatten<MakeOptional<OptionalProps<T>> & RequiredProps<T>>;

type DeepConvertObject<T> = ConvertObject<{ [P in keyof T]: DeepConvert<T[P]> }>;

type DeepConvert<T> = T extends object ? DeepConvertObject<T> : T;

type After = DeepConvert<Before>;
type SimpleAfter = DeepConvert<Simple>;

const fnBefore = (input: Before) => {
  return input;
};

const fnAfter = (input: After) => {
  return input;
};

Expected behavior:
The tooltip should be shown without aliases.

Actual behavior:
The tooltip is shown with aliases.

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label Mar 15, 2018
@yortus
Copy link
Contributor

yortus commented May 10, 2018

I'm seeing this too with a fairly similar recursive type. My use cases have only 1-2 levels of recursion. The compiler infers the recursive types correctly, but intellisense shows aliases after a single level of recursion.

I'm not sure the OP's quote from #22011 applies, since it's not clear that is talking about intellisense rather than the actual type within the compiler.

Regardless, it would be helpful when working in VSCode (or other IDEs) if intellisense didn't abbreviate the type quite so soon.

An example from my case:

// the full type as inferred inside the compiler:
{
    methods: {};
    modules: {
        user: {
            methods: {
                bar: {
                    in: t.string;
                    out: t.number;
                };
            };
            modules: {};
        };
    };
};

// the same type as reported by intellisense:
{
    methods: {};
    modules: {
        user: Flatten;
    };
};

If I inline Flatten<T> into the other recursive types, then intellisense aliases another one of the mutually-recursive types instead.

@lstkz
Copy link
Author

lstkz commented Jan 9, 2019

@yortus
Any update? Here is my library that has above issue https://github.com/BetterCallSky/veni.
It works fine in autocomplete after pressing ..
But it's not user-friendly when you hover on the variable.

image

TypeScript Version: 3.2.2

@lchimaru
Copy link

lchimaru commented May 7, 2019

@BetterCallSKY have you tried just simply flattening the type? Look at this:

type DeepConvert<T> = T extends object ? { [P in keyof DeepConvertObject<T>]: DeepConvertObject<T>[P] } : T;

I know, it's showing following error

Type 'P' cannot be used to index type 'ConvertObject<{ [P in keyof T]: DeepConvert<T[P]>; }>'.

but surprisingly it works as it should. Take a look on hovering effect:
Zrzut ekranu z 2019-05-07 14-11-54

@RyanCavanaugh RyanCavanaugh added Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript and removed Needs Investigation This issue needs a team member to investigate its status. labels Aug 22, 2019
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Aug 22, 2019
@RyanCavanaugh
Copy link
Member

If anyone can figure out a type alias printing strategy that doesn't regress current scenarios or generate stack overflows, we'd be happy to get a PR. The current rules are our best effort at heuristics that result in overall good results.

@lstkz
Copy link
Author

lstkz commented Oct 31, 2019

@lchimaru
wow, your fix works!

@RyanCavanaugh
it seems like we must add guards T extends object and aliasing works for many nested levels.

@akutruff
Copy link

akutruff commented Feb 9, 2021

@RyanCavanaugh

As per your request for suggestions on heuristic improvements, would the TS team be open to user supplied compiler hints for when the user is dealing with complex recursive conditional types?

The work around for this issue is the same for #28508 (comment) which is to flatten the hierarchy with another recursive conditional. That actually put the user at even more risk of hitting #34933. (I'm running typescript@next and keep hitting the infinite recursion problem.)

Similar to the the example here: #28508 (comment), and above, my code follows the same pattern:

//NOTE: Details of this don't matter for this thread, but users are seemingly converging on this pattern 
//       for mapping hierarchies.  
export type ShapeMapper<T> =  { [P in keyof T]: Shape<T[P]> };

export type Shape<TDefinition> =
    TDefinition extends LiteralType<infer LiteralKind>
    ? LiteralKind
    : TDefinition extends Type<'string'>
    ? string
    : TDefinition extends Type<'number'>
    ? number
    : TDefinition extends Type<'boolean'>
    ? boolean
    : TDefinition extends Type<'bigint'>
    ? bigint
    : TDefinition extends Type<'null'>
    ? null
    : TDefinition extends Type<'undefined'>
    ? undefined
    : TDefinition extends ArrayType<infer ElementKind>
    ? Array<Shape<ElementKind>>
    : TDefinition extends UnionType<infer KeyKind>
    ? Shape<KeyKind>
    : TDefinition extends ObjType<infer TShapeDefinition>
    ? ShapeMapper<TShapeDefinition> 
    : never;

Then we use the template on our actual type schemas. The following is the pattern used by many if not all the TypeScript validation libs, like io-ts, zod, etc. All of our application types are defined in this inverted way.

//Define schema
const Person = t.obj({
    name: t.str,
});

const Address = t.obj({
    owner: Person
});

//Infer the TypeScript type
type PersonShape = Shape<typeof Person>;
type AddressShape = Shape<typeof Address>;

But, this destroys intellisense as relations emerge. (The Address[owner] property is unreadable) So we use this work around

export type FlattenForIntellisense<T> = T extends object ? {} & { [P in keyof T]: FlattenForIntellisense<T[P]> } : T;

type PersonShape = FlattenForIntellisense<Shape<typeof Person>>;
type AddressShape = FlattenForIntellisense<Shape<typeof Address>>;

Proposal?

As the hierarchies start to grow deeper, the problem gets worse while the domain types like Person, and Address are natural caching points for the compiler to heuristically prune the search space. These domain objects are also where we want intellisense to simplify generics. The compiler doesn't know that Person and Address are where it can cache.

Instead, what if we give the compiler a hint to know when to fully compute and simplify a type, and then treat that type like an explicit type declaration from now on? From then on, the compiler and IDE treats Person and Address like as if the user had entered them as text in a .ts file. Intellisense would also ignore the underlying templates that defined the template driven domain type, and always show the most simplified form with recursion fully computed.

Some options:

Option 1: A new intrinsic. Pick a good name, I suck at naming.

type PersonShape = ResolveTypeFully<Shape<typeof Person>>;
type AddressShape = ResolveTypeFully<Shape<typeof Address>>;

Option 2: Whenever a type alias derives from an interface, then the type is fully inferred, and the compiler fully resolves the type as described above. Since I believe an interface can only extend a type that can be fully computed, then maybe this a natural spot. However, this is very verbose, and makes a weird empty interface in the code without clear purpose. Also, if the compiler ever does relax this requirement, then it breaks.

interface PersonShape extends Shape<typeof Person> { }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants