3
type ResourceA = { type: 'A', value: 'A1' | 'A2' | 'A3' };
type ResourceB = { type: 'B', value: 'B1' | 'B2' };
type Resource = ResourceA | ResourceB;

function singleParam(resource: Resource) {
  // ...
}

function multiParams<R extends Resource, T extends R['type'], V extends R['value']>(type:T, value: V) {
  // ... type and value should be 
}

// Examples
singleParam({ type: 'A', value: 'A1' });
singleParam({ type: 'A', value: 'B1' }); // type error

multiParams('A', 'A1');
multiParams('A', 'B1'); // no type error

Link to Typescript Playground

Is there any way to make this last example cause a type error, just like in the second example, but only using two parameters (type and value)?

I tried this solution, but it would require me to add a third parameter like resource: R to the multiParams function. Anyway, in that case, the first fn singleParam would be simpler.

  • Looks similar to https://stackoverflow.com/questions/62699531/typescript-narrowing-type-from-generic-union-type If that's indeed the case, looks like you're out of luck. – Thomas Sep 17 '22 at 18:43
  • Does [this approach](https://tsplay.dev/wORbyW) meet your needs? If so I could write up an answer explaining; if not, what am I missing? (If you reply please mention @jcalz so I'll be notified) – jcalz Sep 17 '22 at 18:53
  • @jcalz thanks!! it works, but unfortunately IDE (vscode, tsplayground) autocomplete doesn't work perfectly. I would like to be able to aucomplete the second parameter based on the the first parameter too. But I consider it an acceptable answer. – gabrielmoreira Sep 19 '22 at 11:58
  • Would you prefer [this approach](https://tsplay.dev/WGRMKm) instead? There's a tradeoff between ideal call side behavior and ideal implementation side behavior, and right now your implementation is empty. Can you test the two suggestions to see if either has unacceptable implications for the implementation (and if so, add enough code to the question to demonstrate this)? Let me know how I should proceed. – jcalz Sep 19 '22 at 13:43
  • @jcalz second approach seems to work perfectly. Both solutions are interesting, but the second one fits better to my use case. Thank you! – gabrielmoreira Sep 23 '22 at 13:40
  • @jcalz i don't see need to add more code. Your suggestion fixed exactly what i need. Thanks! You could send an answer explaining both approaches and I will accept ofc, but please make sure to list your second suggestion as the main answer. The multiParams reflects my actual implementation. – gabrielmoreira Sep 23 '22 at 14:58
  • Okay I will write up an answer when I get a chance – jcalz Sep 23 '22 at 15:54

1 Answers1

2

I can think of two approaches.


The first is to make multiParams() accept a rest parameter of a union of tuple types which we can immediately destructure into correlated variables type and value. It could look like this:

type MultiParamsArgs = Resource extends infer R ? R extends Resource ?
  [type: R['type'], value: R['value']]
  : never : never;

/* type MultiParamsArgs = 
     [type: "A", value: "A1" | "A2" | "A3"] | 
     [type: "B", value: "B1" | "B2"];
*/

function multiParams(...[type, value]: MultiParamsArgs) { }

Here I use a distributive conditional type over the union in Resource, to produce the MultiParamsArgs type, which you can see is the union of acceptable types for the argument list of multiParams().

And indeed, calls to multiParams() will accept correct parameters and reject incorrect ones:

multiParams('A', 'A1');
multiParams('A', 'B1'); // error
multiParams(Math.random() < 0.999 ? "A" : "B", "B1") // error here too

A benefit to this approach is that the type checker will interpret the type/value pair of variables as pieces of a discriminated union, and you can do narrowing on them accordingly:

function multiParams(...[type, value]: MultiParamsArgs) { 
  if (type === "A") {
    const x = { A1: 1, A2: 2, A3: 3 }[value] // okay
  }
}

A drawback is that IntelliSense support isn't great; in some sense a union of rest tuples acts like an overloaded function, but you will sometimes only shown the "first" overload in your editor. There's an open issue at microsoft/TypeScript#31977 asking for improvement there, but for now this is a limitation.


The second approach is to make multiParams() generic in the key type K of a helper mapping type MultiParamsMap, so we can represent the connection between the type K of type and the indexed access type MultiParamsMap[K] of value. It could look like this:

type MultiParamsMap = { [T in Resource as T['type']]: T['value'] }
/* type MultiParams = {
    A: "A1" | "A2" | "A3";
    B: "B1" | "B2";
} */

function multiParams<K extends keyof MultiParamsMap>(
  type: K, value: MultiParamsMap[K]) { }

So MultiParamsMap is a mapped type with remapped keys over Resource. For most normal calls of multiParams(), this accepts correct arguments and rejects incorrect ones.

multiParams('A', 'A1');
multiParams('A', 'B1'); // error

And IntelliSense is decidedly useful; as soon as you write multiParams('A', the compiler infers "A" for K and then prompts you to provide "A1" | "A2" | "A3" for the second parameter. So that's a benefit.

The drawback here is that the type checker is unable to understand that checking type has any effect on value, so you can't use them like a discriminated union:

function multiParams<K extends keyof MultiParamsMap>(
  type: K, value: MultiParamsMap[K]) {

  if (type === "A") {
    const x = { A1: 1, A2: 2, A3: 3 }[value] // error!
  }

}

And in fact it is technically possible to pass in a value that doesn't correspond to type the way you want:

multiParams(Math.random() < 0.999 ? "A" : "B", "B1") // okay?!

That is accepted because K is inferred as the union "A" | "B", and so MultiParamsMap[K] is the full "A1" | "A2" | "A3" | "B1" | "B2" union. And thus "B1" is accepted as "value" even though there's a 99.9% chance that type is "A". This sort of thing doesn't happen often in practice, so you probably don't have to worry about it from the caller side, but this possibility is one reason why the implementation narrowing doesn't happen. There are open issues about this at microsoft/TypeScript#27808 and microsoft/TypeScript#33014, but for now this is a limitation.


If IntelliSense is the most important concern for you, then you probably want the generic function solution.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Let's suppose, in the mapper help, instead of mapping like {[string]: string}, I want to map more than one field, like {[string}, {values: string, fieldB: string, fieldC: number}}. I can't express `value: MultiParamsMap[K]['values']` because typescript gives me this error `Type '"values"' cannot be used to index type ...` Is this error related to this https://github.com/microsoft/TypeScript/issues/27808 ? Is there any workaround? Please check this code: https://tsplay.dev/w23jzN – gabrielmoreira Sep 25 '22 at 14:46
  • This looks like a followup question and therefore probably belongs in its own post, since I can't guarantee that I'll get a chance to figure it out and almost nobody else is going to be looking at these comments. From my brief look at that playground link your `multiParams1()` function has a number of issues, like too many uninferrable type parameters, an erroneous assumption that partial type argument inference is possible, and using deep generic indices, each of which could probably use a lot of discussion and research. Comments aren't a great place to do any of that. – jcalz Sep 25 '22 at 17:05
  • I created this other question with a bit more context: https://stackoverflow.com/questions/74075239/type-id-cannot-be-used-to-index-type @jcalz If you have time, please take a look. Thanks anyway. – gabrielmoreira Oct 14 '22 at 22:09