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

Reverse mapped type with a circular type param sometimes not treated as partially inferrable #48798

Open
Andarist opened this issue Apr 21, 2022 · 5 comments · May be fixed by #54029
Open

Reverse mapped type with a circular type param sometimes not treated as partially inferrable #48798

Andarist opened this issue Apr 21, 2022 · 5 comments · May be fixed by #54029
Labels
Needs Investigation This issue needs a team member to investigate its status.
Milestone

Comments

@Andarist
Copy link
Contributor

Andarist commented Apr 21, 2022

Bug Report

🔎 Search Terms

reverse mapped types, circular type, partially inferrable

⏯ Playground Link

Playground link with relevant code

💻 Code

type AnyFunction = (...args: any[]) => any;

type InferNarrowest<T> = T extends any
  ? T extends AnyFunction
    ? T
    : T extends object
    ? InferNarrowestObject<T>
    : T
  : never;

type InferNarrowestObject<T> = {
  readonly [K in keyof T]: InferNarrowest<T[K]>;
};

type Config<TGlobal, TState = Prop<TGlobal, "states">> = {
  states: {
    [StateKey in keyof TState]: {
      on?: {};
    };
  };
} & {
  initial: keyof TState;
};

type Prop<T, K> = K extends keyof T ? T[K] : never;

const createMachine = <TConfig extends Config<TConfig>>(
  _config: InferNarrowestObject<TConfig>
): void => {};

createMachine({
  initial: "pending",
  states: {
    pending: {
      on: {
        done() {
          return "noData";
        },
      },
    },
  },
});

🙁 Actual behavior

Inferred signature is:

const createMachine: <Config<{
    initial: "pending";
    states: unknown;
}, unknown>>(_config: InferNarrowestObject<Config<{
    initial: "pending";
    states: unknown;
}, unknown>>) => void

🙂 Expected behavior

states: unknown shouldn't be inferred here, the reverse mapped type should be able to infer an object literal type~. The type param should be inferred as something like:

const createMachine: <{
    initial: "pending";
    states: {
        pending: {
            on: {
                done: () => "noData";
            };
        };
    };
}>(_config: InferNarrowestObject<{
    initial: "pending";
    states: {
        pending: {
            on: {
                done: () => "noData";
            };
        };
    };
}>) => void

We can simplify the repro a little bit, but it will then yield an error at a different position and I can't verify right now if the underlying issue is exactly the same in this case (although from what it looks like the root cause is super similar):
TS playground without conditional type applied at the argument position

Note that we can fix both playground by using an arrow function instead of a method (probably related to a possible "hidden" this type param that makes this context sensitive in the case of a method).

The first one can be fixed by adding a dummy property to the object containing a method (this makes the object partially inferrable):
TS playground with a dummy property added

Especially given that a dummy property fixes the problem it looks like a weird design limitations.

What I've learned when debugging this:

  1. methods + functions with arguments are context sensitive and in checkFunctionExpressionOrObjectLiteralMethod they return anyFunctionType
  2. anyFunctionType has ObjectFlags.NonInferrableType on it
  3. this flag is "propagating" and thus is set on the "parent" object
  4. uninstantiatedType for the on property of this argument gets computed to {} (so it's empty~)
  5. and thus it doesn't pass isPartiallyInferableType check when resolving the structure of the reverse mapped type, since the object has ObjectFlags.NonInferrableType on it AND there are no other properties that would be treated as partially inferrable
  6. since no structure is resolved for this mapped type the unknown is returned for the states property
  7. this in turn makes initial property to error because keyof unknown is never

while a workaround is "known" here (we've learned this hard way though)~ the whole thing still has some problems because we can't provide a param type for the arrow functions contained in on property because once we add any unannotated params to an arrow function it becomes context-sensitive and results in a similar problem:
TS playground with context-sensitive arrow function

Related to #40439

@andrewbranch
Copy link
Member

@ahejlsberg thoughts on this one?

@ahejlsberg
Copy link
Member

The example works as expected if you remove the circular constraint on TConfig:

const createMachine = <TConfig extends object>(
  _config: InferNarrowestObject<TConfig>
): void => {};

So, this tells me that the type inferred for TConfig failed to be assignable to Config<TConfig> and inference therefore picked Config<TConfig> as the inferred type. I'm not sure why the assignment check fails, but could well be because the types aren't right.

@Andarist
Copy link
Contributor Author

Andarist commented Apr 27, 2022

The goal of this kind of type is to get "access" to the type of the argument and remap it on our own, introduce relations between certain keys etc as the input acts as a schema. In this particular example, I'm restricting TArgument['initial'] to be of type keyof TArgument["states"]. This isn't something that is documented or anything - it's just a technique discovered by other people (I've learned it from them).

I'm not sure if we can conclude here that the inferred type failed to be assignable to the constraint, or maybe it was - but then the question/problem would be why an unassignable type was inferred there.

Even if remove some unusual stuff from this repro case then it still leaves me with confusing results:

type Config<TGlobal extends { states: unknown }> = {
  initial: keyof TGlobal["states"];
  states: {
    [StateKey in keyof TGlobal["states"]]: {
      on?: Record<string, () => string>;
    };
  };
};

const createMachine = <TConfig extends Config<TConfig>>(
  _config: TConfig
): void => {};

createMachine({
  initial: 'pending',
  states: {
    pending: {
      on: {
        done() { return ''; }, // errors on initial property because the inferred type is Config<unknown>
        // done: () => { return ''; }, // just works
        // done: () => { return 42 } // errors correctly because the return type doesnt match
      },
    },
  },
});

And if we take a look at one of the mentioned workarounds for the original repro (see here), it is quite surprising that a random~ added property just "fixes" this. If the problem would be in the type then I would expect this extra property to just error in the same way - but according to my debugging session it's just related to how inference is currently implemented in relation to context sensitive functions. Just note that in this repro with an extra property this is still an context sensitive function - the extra property doesn't really change that trait. It just makes the parent object "partially inferrable" and then the inference algorithm is able to proceed further and accept this method there.

Note that if I add more types to the on property then I can further validate that TS sees my types and is able to type-check using them (as long as I have that extra property that makes the thing partially inferrable), see this playground

@andrewbranch andrewbranch added the Needs Investigation This issue needs a team member to investigate its status. label Apr 27, 2022
@andrewbranch andrewbranch added this to the TypeScript 4.8.0 milestone Apr 27, 2022
@andrewbranch andrewbranch self-assigned this Apr 27, 2022
@andrewbranch
Copy link
Member

Setting a milestone to look at this because I’m intrigued, but there is a significant chance I won’t be able to get to it in that timeframe or I won’t find anything actionable.

@Andarist
Copy link
Contributor Author

@andrewbranch happy to hop on a call if you would be up for it - I've spent a few hours investigating this in the debugger so I have a good understanding of what happens and where.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Investigation This issue needs a team member to investigate its status.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants