1

In my file, I have the following discriminated union type

interface SlackStageQuery {
  stage: 'slack';
}

interface GithubStageQuery {
  stage: 'github';
  id: string;
}

interface SuccessStageQuery {
  stage: 'success';
  name: string;
  link: string;
}

type StageQuery = SlackStageQuery | GithubStageQuery | SuccessStageQuery;

Additionally, I have the following interface:

interface Details<S extends OnboardingStage> {
  position: number;
  title: S extends 'success' ? (name: string) => string : string;
  subtitle: string;
  button: (query: StageQuery & { stage: S }) => JSX.Element;
}

Then, I have an arbitrary object ONBOARDING_STAGE_DETAILS which conforms to { [p in OnboardingStage]: Details<p> }.

I also have a query variable of type StageQuery.

To get the button property from one of the sub-objects of ONBOARDING_STAGE_DETAILS, I do

const foo = ONBOARDING_STAGE_DETAILS[query.stage].button(query)

For some reason however, I get this type error:

TS2345: Argument of type 'StageQuery' is not assignable to parameter of type 'never'.   Type 'SlackStageQuery' is not assignable to type 'never'.

I'm not sure why this is, since, for example, if query.stage === 'slack', then ONBOARDING_STAGE_DETAILS[query.stage] would be of type Details<'slack'>, whose button property takes a StageQuery & { stage: 'slack' }, which it is being provided. So I would think all the types would be able to work.

Anyone know why this is and how I can fix it? Thanks!

Richard Robinson
  • 867
  • 1
  • 11
  • 38
  • This is another instance of TypeScript's lack of good support for what I've been calling "correlated union types". I'll add it to the list at [microsoft/TypeScript#35810](https://github.com/microsoft/TypeScript/issues/30581). You can see the answer to [the other question](https://stackoverflow.com/questions/61450183/typescript-dependent-string-literal-properties-and-indexing) for details. The choices you have are either writing redundant code or writing less type-safe code: see [this Playground link](https://tsplay.dev/WKkrKW). – jcalz Apr 12 '21 at 00:52

2 Answers2

1

I cannot fully explain the type error, but I feel as though it is because query is a union type at compile time, then the inferred type of the parameter in the button(query) {} method will also be a union, and somehow/somewhere the intersection with { stage : } cannot be resolved, hence the never type was resolved.


Thinking out loud here...

declare const query: StageQuery;
const details = ONBOARDING_STAGE_DETAILS[query.stage];    // (1)

details.button(query);    // (2)

(1): query is of type SlackStageQuery | GithubStageQuery | SuccessStageQuery. query.stage is of type OnboardingStage, which is a union type of "success" | "slack" | "github". So details is of type Details<S> where S = OnboardingStage.

(2): Expanding the type of the parameter of details.button():

StageQuery & { stage: S }
=> StageQuery & { stage: "success" | "slack" | "github" }
=> (SlackStageQuery | GithubStageQuery | SuccessStageQuery) & { stage: "success" | "slack" | "github" }
=> (SlackStageQuery & { stage: "success" | "slack" | "github" }) | ...
=> /* typescript internal type resolution */
=> never(?!)

If anyone else can figure out how this resolves to never, or whether this is the correct approach to debugging the type resolution, I'd be very happy to learn too :)


As for how to fix, you can take advantage of key remapping to redefine your interface to avoid the intersection type altogether:

interface Details<Query extends StageQuery> {
               // ^~~~~~~~~~~~~~~~~~note the change here
  position: number;
  title: Query["stage"] extends 'success' ? (name: string) => string : string;
  subtitle: string;
  button: (query: Query) => any;
}

type OnboardingStage = StageQuery["stage"];

declare const ONBOARDING_STAGE_DETAILS: {
    [query in StageQuery as StageQuery["stage"]]: Details<query>
    // ^~~~~ map over the StageQueries, but the object is indexed by StageQuery["stage"]
}

declare const query: StageQuery;
ONBOARDING_STAGE_DETAILS[query.stage].button(query)  // OK! :)

const ghdetails: Details<GithubStageQuery> = {
  position: 1,
  title: "",
  subtitle: "",
  button: (query) => { /*query inferred as GitHubQuery */}
}

Playground link

Anson Miu
  • 1,171
  • 7
  • 6
  • @Andson Miu that mostly works, however this now breaks: `query.stage === 'success' ? ONBOARDING_STAGE_DETAILS[query.stage].title(query.name) : ONBOARDING_STAGE_DETAILS[query.stage].title` – Richard Robinson Apr 11 '21 at 23:26
-1

Since you're using an union type already (StageQuery) you don't need to also use an intersection (every variable of type StageQuery will already have a stage property) so you can simply remove it and the code will work:

interface Details<S extends OnboardingStage> {
  position: number;
  title: S extends 'success' ? (name: string) => string : string;
  subtitle: string;
  button: (query: StageQuery) => any;
}

Link to a playground: https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgMoBtEGtVjgcwgEUBXaAT2QG8AoZZAZz0IC5kByBzBLdgbhoBfGjVCRYiFAHFgYABYkARrgLEyUSrXpNVbdvlkLF-OsmAATNkyih8A4aPDR4SNCQRIGDFYVIVqpjqsHAzungwm9CBwALYQVmA2IHam6KBYCUkpDmDkAA4oPmr+ALxo3DjMxRrIAD7IMvJKRX419ahhEF4t6uQCNLkFyADyIIoA9nBQ5rZFyGWcFex1HAZNxiucnV4mjuIuKAAiEHjA6AwAPKjIEAAekCDmDCNjk9OzVQB8AfR54wyyYDjEBsEAkGKKaACehgWToeJoG73CCPZ5bDxdCLIAD8yAAFNE4plbABKebfay2ZDE5LQxhKWFgeE0lL0RQkMBgYFsPEAR16bB6FDJJW+cBAfSEInMEAQmCgKAQwKYyH5FEFVVakpoMrlU0VyrAIwAcgAhYYAQQASocAJLGqQAfVQABULVIAKKOw4et22gAyqDYVGQAG0AApmEAvCZTGbJIoAXTYx1O5wu4e+gn6SpAKpg43G8xN5utdodzrdnu9votAdQobVGgAdEEIInm+zOcC+b0SXwgA

Lars
  • 554
  • 1
  • 5
  • 19
  • 1
    I think the OP's intention of using the intersection type is to define specialised objects that implement the `Details` interface with `button` methods that take the _specific_ query type in its parameter, e.g. some `successDetails` object of type `Details<"success">` that has method `button(query: SuccessQuery)` – Anson Miu Apr 11 '21 at 20:18
  • @AnsonMiu Yes, that is correct. – Richard Robinson Apr 11 '21 at 20:25
  • In that case there was nothing wrong with it. Have a look at the edited playground. – Lars Apr 11 '21 at 22:52