TypeScript does not use generic key consistently

47 views Asked by At

In the following code I use a generic that extends the key of an interface. TypeScript incorrectly allows me to use types from different properties of the object, unless I explicitly pass in a key as a generic or argument.

interface Versions {
  v1: {
    input: {
      v1Input: boolean
    }
    output: {
      v1Output: boolean
    }
  }
  v2: {
    input: {
      v2Input: boolean
    }
    output: {
      v2Output: boolean
    }
  }
}

// This version does not work correctly
function io <T extends keyof Versions> (
  input: Versions[T]['input'],
  output: Versions[T]['output'],
) {}

// This should throw a type error because the input and output do not match, but it does not
io({ v1Input: true }, { v2Output: true }) 

// Adding this explicit generic correctly throws an error:
// "Object literal may only specify known properties, but 'v2Output' does not exist in type '{ v1Output: true; }'. Did you mean to write 'v1Output'?(2561)""
io<'v1'>({v1Input: true }, { v2Output: true }) 

// This version works correctly
function io2 <T extends keyof Versions> (
  input: Versions[T]['input'],
  output: Versions[T]['output'],
  key: T
) {}

// Correctly throws an error
io2({ v1Input: true }, { v2Output: true }, 'v1')

// Adding a second generic doesn't seem to help
function io3 <T extends keyof Versions, V extends Versions[T]> (
  input: V['input'],
  output: V['output'],
) {}

// Does not throw an error
io3({ v1Input: true }, { v2Output: true })

How can I make TypeScript correctly throw and error when I use different generic keys for different arguments?

1

There are 1 answers

0
jcalz On BEST ANSWER

Currently TypeScript cannot infer a generic type parameter when it is only used as a key in an indexed access type. You're trying to infer T from Versions[T]['input'], but Versions[T] is an indexed access type using T as the key. So this won't work. Inference for T therefore fails and falls back to its constraint which is keyof Versions, the union type of all the keys of Versions, meaning that the call to io() will accept any output and any input that appears in Versions, regardless of whether or not they are related to each other.

Maybe someday inference from generic indexes will be supported, possibly if microsoft/TypeScript#53017 is merged, but for now we will need to refactor your code to something where inference is known to work.


The easiest way to get inference working is to design things so that you are trying to infer a generic type I from a value of type I directly. Then the inference is just "use the same type", which is straightforward (as opposed to Versions[T]['input'] which would involve enumerating members of the constraint for T and trying each one out). So we want something like:

function io<I extends C>(
    input: I,
    output: O<I>
) { }

where C is the constraint we want for input, and O<T> is a type which calculates the output type from I.

For C it looks like you want to accept all values of type Versions[keyof Versions]['input'], meaning if you have v of type Versions, and k of type keyof Versions, then const c = v[k].input would be of type C. So that gives us

function io<I extends Versions[keyof Versions]['input']>(
    input: I,
    output: O<I>
) { }

And now we need to compute O<I>. This is trickier, but one approach we can take is to write a distributive object type (as coined in microsoft/TypeScript#47109). The idea is we'd write something like {[K in keyof Versions]: F<K>}[keyof Versions]. That's a mapped type into which we immediately index with keyof Versions to get the full union of properties in the mapped object. For example, since keyof Versions is "v1" | "v2", then {[K in keyof Versions]: F<K>}[keyof Versions] would be {v1: F<"v1">, v2: F<"v2">}["v1" | "v2"], which is F<"v1"> | F<"v2">. So now all we need to do is write F<K> so that it evaluates to the desired output type. This will end up looking like a filtering operation; probably only one of those keys is the right one, so if I is (say, Versions["v1"]["input"], then we want F<"v1"> to be Versions["v1"]["output"], and F<"v2"> to be never, so that the output is Versions["v1"]["output"] | never which is just Versions["v1"]["output"]. Okay, so right now we have

function io<I extends Versions[keyof Versions]['input']>(
    input: I,
    output: { [K in keyof Versions]: F<K> }[keyof Versions]
) { }

And F<K> will have some dependency on I. Here's the way I'd do it:

function io<I extends Versions[keyof Versions]['input']>(
    input: I,
    output: { [K in keyof Versions]:
        I extends Versions[K]['input'] ? Versions[K]['output'] : never
    }[keyof Versions]
) { }

Basically that's a conditional type were if I is assignable to Versions[K]["input"], then we output Versions[K]["output"], and otherwise never. So if I is Versions["v1"]["input"], then that conditional type will be Versions["v1"]['output'] when K is "v1", and never when K is "v2".


Okay, let's try it out:

io({ v1Input: true }, { v2Output: true }) // error!
// -------------------> ~~~~~~~~
// 'v2Output' does not exist in type '{ v1Output: boolean; }'
// function io<{ v1Input: true; }>(
//   input: { v1Input: true; }, output: { v1Output: boolean; }): void

io({ v2Input: true }, { v2Output: true }); // okay
// function io<{ v2Input: true; }>(
//   input: { v2Input: true; }, output: { v2Output: boolean; }): void

Looks good. When you call io({v1Input:true},⋯), the compiler infers I as {v1Input:true}, and then calculates the type of output as {v1Output:boolean}, as expected, and complains about {v2Output: true}. When you call io({v2Input:true},⋯), the compiler infers I as {v2Input:true}, and then calculates the type of output as {v2Output:boolean}, as expected, and accepts {v2Output: true}.

Playground link to code