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?
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
fromVersions[T]['input']
, butVersions[T]
is an indexed access type usingT
as the key. So this won't work. Inference forT
therefore fails and falls back to its constraint which iskeyof Versions
, the union type of all the keys ofVersions
, meaning that the call toio()
will accept anyoutput
and anyinput
that appears inVersions
, 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 typeI
directly. Then the inference is just "use the same type", which is straightforward (as opposed toVersions[T]['input']
which would involve enumerating members of the constraint forT
and trying each one out). So we want something like:where
C
is the constraint we want forinput
, andO<T>
is a type which calculates the output type fromI
.For
C
it looks like you want to accept all values of typeVersions[keyof Versions]['input']
, meaning if you havev
of typeVersions
, andk
of typekeyof Versions
, thenconst c = v[k].input
would be of typeC
. So that gives usAnd 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 withkeyof Versions
to get the full union of properties in the mapped object. For example, sincekeyof Versions
is"v1" | "v2"
, then{[K in keyof Versions]: F<K>}[keyof Versions]
would be{v1: F<"v1">, v2: F<"v2">}["v1" | "v2"]
, which isF<"v1"> | F<"v2">
. So now all we need to do is writeF<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 ifI
is (say,Versions["v1"]["input"]
, then we wantF<"v1">
to beVersions["v1"]["output"]
, andF<"v2">
to benever
, so that the output isVersions["v1"]["output"] | never
which is justVersions["v1"]["output"]
. Okay, so right now we haveAnd
F<K>
will have some dependency onI
. Here's the way I'd do it:Basically that's a conditional type were if
I
is assignable toVersions[K]["input"]
, then we outputVersions[K]["output"]
, and otherwisenever
. So ifI
isVersions["v1"]["input"]
, then that conditional type will beVersions["v1"]['output']
whenK
is"v1"
, andnever
whenK
is"v2"
.Okay, let's try it out:
Looks good. When you call
io({v1Input:true},⋯)
, the compiler infersI
as{v1Input:true}
, and then calculates the type ofoutput
as{v1Output:boolean}
, as expected, and complains about{v2Output: true}
. When you callio({v2Input:true},⋯)
, the compiler infersI
as{v2Input:true}
, and then calculates the type ofoutput
as{v2Output:boolean}
, as expected, and accepts{v2Output: true}
.Playground link to code