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
TfromVersions[T]['input'], butVersions[T]is an indexed access type usingTas the key. So this won't work. Inference forTtherefore 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 anyoutputand anyinputthat 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
Ifrom a value of typeIdirectly. 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 forTand trying each one out). So we want something like:where
Cis the constraint we want forinput, andO<T>is a type which calculates the output type fromI.For
Cit looks like you want to accept all values of typeVersions[keyof Versions]['input'], meaning if you havevof typeVersions, andkof typekeyof Versions, thenconst c = v[k].inputwould 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 Versionsto get the full union of properties in the mapped object. For example, sincekeyof Versionsis"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 ifIis (say,Versions["v1"]["input"], then we wantF<"v1">to beVersions["v1"]["output"], andF<"v2">to benever, so that the output isVersions["v1"]["output"] | neverwhich 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
Iis assignable toVersions[K]["input"], then we outputVersions[K]["output"], and otherwisenever. So ifIisVersions["v1"]["input"], then that conditional type will beVersions["v1"]['output']whenKis"v1", andneverwhenKis"v2".Okay, let's try it out:
Looks good. When you call
io({v1Input:true},⋯), the compiler infersIas{v1Input:true}, and then calculates the type ofoutputas{v1Output:boolean}, as expected, and complains about{v2Output: true}. When you callio({v2Input:true},⋯), the compiler infersIas{v2Input:true}, and then calculates the type ofoutputas{v2Output:boolean}, as expected, and accepts{v2Output: true}.Playground link to code