DEV Community

Manuel Artero Anguita 🟨
Manuel Artero Anguita 🟨

Posted on

Advanced Ts: Dependent parameters, inferred unions and a healthy interaction on Twitter.

Every time I write as Foo in TypeScript, I feel the weight of defeat.

There's one scenario where this feeling is particularly intense: when a function takes a parameter that depends on which "mode" is active.

clearer with some example code:

type Provider = "PROVIDER A" | "PROVIDER B";
type ProviderAOpts = { ... };
type ProviderBOpts = { ... };

function connect(provider: Provider, options: ProviderAOpts | ProviderBOpts)  {
  switch (provider) {
    case "PROVIDER A":
      // options is ProviderAOpts
    case "PROVIDER B":
      // options is ProviderBOpts
  }
}
Enter fullscreen mode Exit fullscreen mode

(I tried to use more realistic names rather than foo, goo, dog and cat).

If you’ve spent some time with TypeScript, you might suspect we used to handle this with as ProviderAOpts, as ProviderBOpts.


But there’s a time you slam your fist on the table and claim: "No more!"


1. What doesn't work

The first thing that always comes to my mind in these cases is to use function overloading:

function connect(provider: "PROVIDER A", options: ProviderAOpts): void;
function connect(provider: "PROVIDER B", options: ProviderBOpts): void;

function connect(provider: Provider, options: ProviderAOpts | ProviderBOpts) {
  switch (provider) {
    case "PROVIDER A":
    // (options as ProviderAOpts) ❌
    case "PROVIDER B":
    // (options as ProviderBOpts) ❌
  }
}
Enter fullscreen mode Exit fullscreen mode

Which doesn't work. The function signature is not inferred correctly. The options parameter is always ProviderAOpts | ProviderBOpts. which will resolve to the common union.

Ts doesn't link both parameters correctly.


2. What works but isn't linking the parameters

The next tool I try are Type Predicates:

type ConnectOptions = ProviderAOpts | ProviderBOpts;

function isAOptions(options: ConnectOptions): options is ProviderAOpts {
  return (options as ProviderAOpts).$$$ !== undefined;
}

function isBOptions(options: ConnectOptions): options is ProviderBOpts {
  return (options as ProviderBOpts).$$$ !== undefined;
}

function connect(provider: Provider, options: ConnectOptions) {
  switch (provider) {
    case "PROVIDER A":
      if (isAOptions(options)) {
        ...
      }
    case "PROVIDER B":
      if (isBOptions(options)) {
        ...
      }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

But honestly, we did not solve anything. We just moved the as under the rug 🧹. Introduced extra ifs and, we are still not linking the parameters.


3. What doesn't work and makes me cry

Generics. I tried to use generics to link the parameters. Doesn't work:

function connect<T extends Provider>(
  provider: T,
  options: T extends "PROVIDER A" ? ProviderAOpts : ProviderBOpts
) {
  switch (provider) {
    case "PROVIDER A":
    // (options as ProviderAOpts) ❌
    case "PROVIDER B":
    // (options as ProviderBOpts) ❌
  }
}
Enter fullscreen mode Exit fullscreen mode

I tried so hard and got so far
But in the end, it doesn't even matter
I had to fall to lose it all
But in the end, it doesn't even matter
🧑‍🎤


4. What does work but forces us to change the function signature

Modifying the opts parameters adding the provider type does the trick:

type Provider = "PROVIDER A" | "PROVIDER B";

type ProviderOptsBase = {
  provider: Provider;
}

type ProviderAOpts = ProviderOptsBase & {
  provider: "PROVIDER A";
  ...;
};

type ProviderBOpts = ProviderOptsBase & {
  provider: "PROVIDER B";
  ...;
};

function connect(options: ConnectOptions) {
  switch (options.provider) {
    case "PROVIDER A":
      // options is ProviderAOpts ✅
    case "PROVIDER B":
      // options is ProviderBOpts ✅
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the most common solution, but it's not always possible to change the function signature. Or maybe you just don't want to. Matter of principles 🫖.


Twitter to the rescue

Thanks to Mateusz Burzyński (@AndaristRake) and Lenz Weber (@phry)

we can get to... 🥁🥁


5. What does work: the destructured tuple

type Provider = "PROVIDER A" | "PROVIDER B";
type ProviderAOpts = { ... };
type ProviderBOpts = { ... };

function connect(
  ...[provider, options]:
    | ["PROVIDER A", ProviderAOpts]
    | ["PROVIDER B", ProviderBOpts]
) {
  switch (provider) {
    case "PROVIDER A":
      // options is ProviderAOpts ✅
    case "PROVIDER B":
      // options is ProviderBOpts ✅
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode
connect("PROVIDER A", { ... });
connect("PROVIDER B", { ... });
                      ^ autocomplete works 
Enter fullscreen mode Exit fullscreen mode

So the thing is that we are destructuring a tuple (array) with the exact types we want.

The only downside, if we're picky, adding more pairs to the tuple... we can extract a generic type here:


6. What does work: generalized tuple solution

type Provider = "PROVIDER A" | "PROVIDER B";
type ProviderAOpts = { ... };
type ProviderBOpts = { ... };

type ProviderOpts = {
  "PROVIDER A": ProviderAOpts;
  "PROVIDER B": ProviderBOpts;
};

// solves to 
// ["PROVIDER A", ProviderAOpts] | ["PROVIDER B", ProviderBOpts]
type ConnectOptions = {
  [K in keyof ProviderOpts]: [K, ProviderOpts[K]];
}[keyof ProviderOpts]; 

function connect(...[provider, options]: ConnectOptions) {
  switch (provider) {
    case "PROVIDER A":
      // options is ProviderAOpts ✅
    case "PROVIDER B":
      // options is ProviderBOpts ✅
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode
connect("PROVIDER A", { ... });
connect("PROVIDER B", { ... });
                      ^ autocomplete works 
Enter fullscreen mode Exit fullscreen mode

7. TL;DR. COPY PASTE, THANKS

type Provider = "PROVIDER A" | "PROVIDER B";
type ProviderAOpts = { ... };
type ProviderBOpts = { ... };

type ProviderOpts = {
  "PROVIDER A": ProviderAOpts;
  "PROVIDER B": ProviderBOpts;
};

// aux type to extract the key and the options from ProviderOpts
type KeyOpts<T> = {
  [K in keyof T]: [K, T[K]];
}[keyof T];


function connect(...[provider, options]: KeyOpts<ProviderOpts>) {
  switch (provider) {
    case "PROVIDER A":
      // options is ProviderAOpts ✅
    case "PROVIDER B":
      // options is ProviderBOpts ✅
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode
connect("PROVIDER A", { ... });
connect("PROVIDER B", { ... });
                      ^ autocomplete works 
Enter fullscreen mode Exit fullscreen mode

Thanks to Mateusz and Lenz for the help 🙏.

thanks for reading 💙.

Top comments (3)

Collapse
 
receter profile image
Andreas Riedmüller

🤔 Somehow I still like that the original code (with as) is less complex.

What do you think? When would you still prefer the original version with “as”?

Collapse
 
manuartero profile image
Manuel Artero Anguita 🟨 • Edited

Thanks for commenting!

Every time you use as, you're essentially bypassing Typescript's type inference system... that's why I try to avoid it, I consider it more of a "last resort Technique"

In this case, it's frustrating that ts inference doesn't handle this out of the box. It feels like you're forced to hack the language.

...

I get what you mean. Which do you prefer—suppressing ts for a line or going with a hacky solution?

in this case, I'm using #4 honestly. But now i have #7 as ultimate Sharingan 🧿

Collapse
 
receter profile image
Andreas Riedmüller • Edited

I think it depends, if you want to go fast and need to get things done, sometimes suppressing/forcing TS is what I prefer (in contrast to loosing a couple of hours).

But if the code should be super clean, e.g., for a library or something you reuse often, I think it pays to do it properly.