question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Nested call not inferred correctly when a conditional type tries to route to the final expected type

See original GitHub issue

Bug Report

🔎 Search Terms

inference, type parameters, conditional type, signature resolving

🕗 Version & Regression Information

This is the behavior present in all 4.x versions available on the playground, including 4.5-beta.

⏯ Playground Link

Slimmed down repro case

The originally reported TS playground

💻 Code

This is the code for the slimmed-down variant. Note that the createMachine2 is not a part of the repro case but it shows how putting the conditional type on a “different level” makes it work - which I find to be surprising because semantically both variants do the same thing

interface EventObject { type: string; }
interface TypegenDisabled { "@@xstate/typegen": false; }
interface TypegenEnabled { "@@xstate/typegen": true; }

type TypegenConstraint = TypegenEnabled | TypegenDisabled;

interface ActionObject<TEvent extends EventObject> {
  type: string;
  _TE?: TEvent;
}

declare function assign<TEvent extends EventObject>(
  assignment: (ev: TEvent) => void
): ActionObject<TEvent>;

declare function createMachine<
  TTypesMeta extends TypegenConstraint = TypegenDisabled
>(
  config: {
    types?: TTypesMeta;
  },
  action?: TTypesMeta extends TypegenEnabled
    ? { action: ActionObject<{ type: "WITH_TYPEGEN" }> }
    : { action: ActionObject<{ type: "WITHOUT_TYPEGEN" }> }
): void;

createMachine(
  {
    types: {} as TypegenEnabled,
  },
  {
    // `action` property is of a correct type - it expects `ActionObject<{ type: "WITH_TYPEGEN" }> }`
    action: assign((event) => {
      // but the type of this `assign` has been inferred to `ActionObject<{ type: "WITHOUT_TYPEGEN" } | { type: "WITH_TYPEGEN" }>`
      // so `event` here is of type `{ type: "WITHOUT_TYPEGEN" } | { type: "WITH_TYPEGEN" }`
      ((_accept: "WITH_TYPEGEN") => {})(event.type);
    }),
  }
);

// below we have the code that is not part of the repro case but which is semantically the same as the one above and yet it behaves differently

declare function createMachine2<
  TTypesMeta extends TypegenConstraint = TypegenDisabled
>(
  config: {
    types?: TTypesMeta;
  },
  action?: {
    // it works correctly if we check the `TTypesMeta` further down the road
    action: TTypesMeta extends TypegenEnabled
      ? ActionObject<{ type: "WITH_TYPEGEN" }>
      : ActionObject<{ type: "WITHOUT_TYPEGEN" }>;
  }
): void;

createMachine2(
  {
    types: {} as TypegenEnabled,
  },
  {
    action: assign((event) => {
      ((_accept: "WITH_TYPEGEN") => {})(event.type);
    }),
  }
);

The code below showcases more accurately what I’m trying to achieve.

Code from the originally reported playground
type Cast<T extends any, TCastType extends any> = T extends TCastType
  ? T
  : TCastType;
type Prop<T, K> = K extends keyof T ? T[K] : never;

type IndexByType<T extends { type: string }> = {
  [K in T["type"]]: Extract<T, { type: K }>;
};

interface EventObject {
    type: string;
}

interface TypegenDisabled {
  "@@xstate/typegen": false;
}
interface TypegenEnabled {
  "@@xstate/typegen": true;
}

type TypegenConstraint = TypegenEnabled | TypegenDisabled;

type ResolveTypegenMeta<
  TTypesMeta extends TypegenConstraint,
  TEvent extends { type: string }
> = TTypesMeta extends TypegenEnabled
  ? TTypesMeta & {
      indexedEvents: IndexByType<TEvent>;
    }
  : TypegenDisabled;

interface ActionObject<TEvent extends EventObject> {
  type: string;
  _TE?: TEvent;
}

interface MachineOptions<TEvent extends EventObject> {
  actions?: {
    [name: string]: ActionObject<TEvent>;
  };
}

type TypegenMachineOptions<
  TResolvedTypesMeta,
  TEventsCausingActions = Prop<TResolvedTypesMeta, "eventsCausingActions">,
  TIndexedEvents = Prop<TResolvedTypesMeta, "indexedEvents">
> = {
  actions?: {
    [K in keyof TEventsCausingActions]?: ActionObject<
      Cast<Prop<TIndexedEvents, TEventsCausingActions[K]>, EventObject>
    >;
  };
};

type MaybeTypegenMachineOptions<
  TEvent extends EventObject,
  TResolvedTypesMeta = TypegenDisabled
> = TResolvedTypesMeta extends TypegenEnabled
  ? TypegenMachineOptions<TResolvedTypesMeta>
  : MachineOptions<TEvent>;

declare function assign<TEvent extends EventObject>(
  assignment: (ev: TEvent) => void
): ActionObject<TEvent>;

declare function createMachine<
  TEvent extends { type: string },
  TTypesMeta extends TypegenConstraint = TypegenDisabled,
>(
  config: {
    schema?: {
      events?: TEvent;
    };
    types?: TTypesMeta;
  },
  options?: MaybeTypegenMachineOptions<TEvent, ResolveTypegenMeta<TTypesMeta, TEvent>>
): void;

interface TypesMeta extends TypegenEnabled {
  eventsCausingActions: {
    actionName: "BAR";
  };
}

createMachine(
  {
    types: {} as TypesMeta,
    schema: {
      events: {} as { type: "FOO" } | { type: "BAR"; value: string },
    },
  },
  {
    actions: {
      // `event` here is not narrowed down to the BAR one
      // yet the `actionName`'s type (available when I hover over the property) is correct
      actionName: assign((event) => {
        ((_accept: "BAR") => {})(event.type);
      }),
    },
  }
);

🙁 Actual behavior

event parameter here is not narrowed down to the WITH_TYPEGEN event, yet the actionName’s type (available when I hover over the property) is “correct” (only WITH_TYPEGEN event there)

🙂 Expected behavior

I would expect this event parameter to be inferred correctly.


I’ve been debugging this for a bit and I think this is roughly what happens:

  1. we have nested chooseOverload+inferTypeArguments calls because assign happens “within” createMachine
  2. typeParameters for assign are assigned based on both branches of the conditional type - basically, a union of both is created for this position and since there is an overlap the reduced type gets assigned there.
  3. the “routing” conditional type gets instantiated only after we exit the outer inferTypeArguments and it happens within getSignatureApplicabilityError but the assign has been instantiated and cached a step back - within inferTypeArguments
  4. the inferred type for assign is never rechecked - so it stays as if it was supposed to handle both branches of the conditional type

Note that this might be highly incorrect as I’m not too familiar with the codebase

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:2
  • Comments:6 (6 by maintainers)

github_iconTop GitHub Comments

2reactions
Andaristcommented, Oct 12, 2021

@millsp has found out that wrapping this conditional type in NoOp type “fixes” the problem:

diff --git a/repro_case.ts b/repro_case.ts
index 37eb31f9e..c78f843e7 100644
--- a/repro_case.ts
+++ b/repro_case.ts
@@ -20,15 +20,19 @@ declare function assign<TEvent extends EventObject>(
   assignment: (ev: TEvent) => void
 ): ActionObject<TEvent>;
 
+type NoOp<T> = { [K in keyof T]: T[K] };
+
 declare function createMachine<
   TTypesMeta extends TypegenConstraint = TypegenDisabled
 >(
   config: {
     types?: TTypesMeta;
   },
-  action?: TTypesMeta extends TypegenEnabled
-    ? { action: ActionObject<{ type: 'WITH_TYPEGEN' }> }
-    : { action: ActionObject<{ type: 'WITHOUT_TYPEGEN' }> }
+  action?: NoOp<
+    TTypesMeta extends TypegenEnabled
+      ? { action: ActionObject<{ type: 'WITH_TYPEGEN' }> }
+      : { action: ActionObject<{ type: 'WITHOUT_TYPEGEN' }> }
+  >
 ): void;
 
 createMachine(

TS playground

0reactions
Andaristcommented, Apr 7, 2022

I think that this would have the potential to fix this if it would be baked into the language.

We are already using NoInfer trick at a couple of places as there is only one place in our complex structure that should act as a possible inference site but the same generic is used all over the place. So even manual annotation with :any at a random place deopts our desired inference.

I’ve tried to apply NoInfer trick here though (and even LowInfer) but none of that helped for this particular case. I think that the part of the problem is that we have additional generic call expressions nested in the second argument and TS starts inferring their type params before it settles on the value of this TTypesMeta. Or something like that 😅

I’m basically on the lookout here for a trick to make TS start processing the second argument only after it settles the first one but I’ve already thrown all the tricks I got at this and I can’t make it work. I’ve briefly thought that forcing the resolution with infer would help me, like here, and it did… but it broke some other type-tests related to those nested call expressions. I think that I would have to make somehow TTypesMeta to be inferred to its default within the type params list or by forcing it somehow within the scope of the first argument - but I have no idea how to make it. This would hopefully, make the second argument to already see the resolved value and that potentially would just satisfy all of my requirements.

Read more comments on GitHub >

github_iconTop Results From Across the Web

When does Java require explicit type parameters?
Once we supply an explicit type, the remaining variables get inferred correctly.
Read more >
[JDK-6357966] parameters containing nested generic types avoid ...
There is a generic type A defining the interdependency between the map's key and value type. But the argument used to call the...
Read more >
Understanding infer in TypeScript - LogRocket Blog
The infer keyword and conditional typing in TypeScript allow us to take a type and isolate any piece of it for later use....
Read more >
typing — Support for type hints — Python 3.11.1 documentation
The Python runtime does not enforce function and variable type annotations. ... objects kept in containers cannot be statically inferred in a generic...
Read more >
Kinds of types - mypy 0.991 documentation
class A: def f(self) -> int: # Type of self inferred (A) return 2 class B(A): def ... If you do not define...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found