I am struggling adding the right types for function parameters, where the function should be generic and the parameters should relate to each other.
Here is a minimal reproducible example (in the actual code, the type Foo
and the object objectOne
come from a library that I cannot change, and the function func
is one I am trying to create myself):
interface Foo {
union: 'one' | 'two' | 'three';
object: {
One: {'oneRelatedKey': 'oneRelatedValue'};
Two: {'twoRelatedKey': 'twoRelatedValue'};
Three: {'threeRelatedKey': 'threeRelatedValue'};
};
};
const objectOne = {
one: {
func: (obj: Foo['object']['One']) => {console.log(obj)}
},
two: {
func: (obj: Foo['object']['Two']) => {console.log(obj)}
},
three: {
func: (obj: Foo['object']['Three']) => {console.log(obj)}
},
};
const func = <T extends Foo['union']>(one: T, two: Foo['object'][Capitalize<T>]) => {
objectOne[one].func(two);
}
I am getting an error on two
:
Property ''twoRelatedKey'' is missing in type '{ oneRelatedKey: "oneRelatedValue"; }' but required in type '{ twoRelatedKey: "twoRelatedValue"; }'
I want to ensure that func
is called with a string from Foo['union']
and the corresponding object from Foo['object']
, indexed on the capitalized version of the passed in Foo['union']
argument.
It should then work to call func
as follows:
func('one', { 'oneRelatedKey': 'foo' })
Note: Only the parameter types should be changed, not the type of objectOne
(the object comes from a library and the MRE is a simplified version).
I tried the following, and it seems closer:
type FirstTestGeneric<T extends Foo['union']> = T;
type FirstTest = FirstTestGeneric<'one'>; // 'one'
type SecondTestGeneric<T extends Foo['union']> = Capitalize<T> extends keyof Foo['object'] ? Foo['object'][Capitalize<T>] : never;
type SecondTest = SecondTestGeneric<'one'>; // {'oneRelatedKey': string}
const testFunc = <T>(one: FirstTestGeneric<'one'>, two: SecondTestGeneric<'one'>) => {
objectOne[one].func(two);
}
Now I just need to somehow modify testFunc
so it uses the type parameter. For some reason, this does not work:
const testFunc = <T extends Foo['union']>(one: FirstTestGeneric<T>, two: SecondTestGeneric<T>) => {
objectOne[one].func(two);
}
I think the problem is that the type T
accepts a union. If it just accepted one string (from the union Foo['union']
), it would probably work.
I found the following: Restrict generic typescript type to single string literal value, disallowing unions
Trying to apply that here, we can do:
type GenericType<T extends Foo['union'] = Foo['union']> = { [U in T]: {
type: U;
parameter: Foo['object'][Capitalize<U>];
} }[T];
const testFuncOne = <T extends Foo['union']>(input: GenericType<T>) => {
objectOne[input.type].func(input.parameter);
}
We still get this error on func(input.parameter)
:
Property ''twoRelatedKey'' is missing in type '{ oneRelatedKey: string; }' but required in type '{ twoRelatedKey: string; }'
For some reason, func
is expecting an argument of the following type:
(property) func: (obj: {
oneRelatedKey: string;
} & {
twoRelatedKey: string;
} & {
threeRelatedKey: string;
}) => void
I have a sneaking suspicion this has something to do with functions being contra-variant in their parameters.
It's possible with a little type manipulation:
interface Foo {
union: 'one' | 'two' | 'three';
object: {
One: {'oneRelatedKey': 'oneRelatedValue'};
Two: {'twoRelatedKey': 'twoRelatedValue'};
Three: {'threeRelatedKey': 'threeRelatedValue'};
};
}
type OneObjType = {
[K in Foo['union']]: { func: (x: Foo['object'][Capitalize<K>]) => void }
}
const objectOne = {
one: {
func: (obj: Foo['object']['One']) => {console.log(obj)}
},
two: {
func: (obj: Foo['object']['Two']) => {console.log(obj)}
},
three: {
func: (obj: Foo['object']['Three']) => {console.log(obj)}
},
};
const retyped: OneObjType = objectOne; // NOTE: type-safe, no cast
const func = <T extends Foo['union']>(one: T, two: Foo['object'][Capitalize<T>]) => {
retyped[one].func(two);
}
Note that the signature of your original function was correct and I haven't modified it. The trick is to get the compiler to accept that the parameters to the methods in objectOne
map back to the union and that the union connects to the corresponding objects in the Foo
interface by using the alias retyped
. Note also this is a reinterpreting of the type not a cast: the compiler still ensures safety.
The reason this works but your original code doesn't has to do with the type of objectOne
, which you index into and call a method on in the body of func
. The compiler does not understand that the keys in objectOne
are related to Foo['union']
and that the parameters to the func
methods of that object relate to the values of Foo['object']
. All the information to determine that is available, we humans can see it, but the compiler has to infer the most general type it can (and in the 99% case that's actually the behavior you want) so it can't just assume what you want it to here.
There are a couple of ways to override that behavior, one is to use the as const
qualifier to tell the compiler to infer the narrowest type it can rather than the widest. But that would require modifying the annotation of the actual objectOne
value, which you can't do since you're getting it from a 3rd party library (it also won't work if objectOne
is mutable and not truly const, as const
will make properties readonly
).
Another way though is to connect the dots for the compiler by assigning an existing value to a compatible but narrower type (than the one it already has), which is the approach I've taken here: I extract a mapped type from Foo
called OneObjType
that makes explicit how the keys and func
method parameters map back to Foo
.
For a simplified example of the "assign to an alias with a narrower type", consider the following:
const x: 'a' | 'b' = 'a';
const y: 'a' = x; // legal narrowing assignment
function foo(a: 'a') {}
foo(x); // ERROR: cannot assign 'a' | 'b' to 'a'
foo(y); // Fine, no error!
x === y; // true
Even though 'a'
is a narrower type than 'a' | 'b'
the assignment to y
is legal because the term-level value is a const
and the compiler can prove it's safe. You can then use y
in a place that requires the literal type 'a'
, but you can't use x
because you can't use 'a' | 'b'
to satisfy a type that requires 'a'
. This is true even though y
is just an alias for x
. They point to the same term-level value (the string literal 'a'
) but x
and y
have different types, and you can do things with one you can't with the other (like pass to foo
).
Making the assignment const retyped: OneObjType = objectOne;
doesn't change objectOne
at all, it just creates a new name for it that has a different but compatible type (which is why the compiler allows the assignment), and we can safely call the related methods by using that different view of the object: retyped[one].func(two);
works because retyped
is just an alias for objectOne
but it has a different type, one that the compiler understands maps back to the Foo
interface. It's just a way to tell the compiler to treat objectOne
as the narrower type OneObjType
rather than the type it already has when we refer to it via the retyped
alias.
This isn't that different than using type narrowing via conditional checks except that it happens purely at compile-time, there's no runtime code that needs to execute to narrow the type, making for a more elegant solution.
Note that all of this works even if you get Foo
and objectOne
from a library because we don't modify either one of them at all, we just spell out the relation they have to each other so the compiler can understand it.