You have several options here, each with its own benefits and drawbacks. First:
const doQuery = <D extends A | B | C, T extends Query<D>>({ query }: { query: T }): D => {
return query.doTheQuery()
}
Good thing about it - type safety inside doQuery implementation. Bad thing, it has its type glitches when you use it.
function foo(q: Query<A>): A {
const a = doQuery({query: q}); // Bad: "a" is typed to A | B | C
const a1: A = a; // Error because A | B | C can not be assigned to A
const b: B = doQuery({query: q}) // Good: it errors in incompatible query
const c: A = doQuery({query: q}) // Good: it assignes without typecasting to the correct type
const c1: A = c // No error
}
Other option is to type it like that
const doQuery = <T extends Query<any>>({ query }: { query: T }): T extends Query<infer V> ? V : never => {
return query.doTheQuery()
}
Good: it has the best declaration in terms of usage (like option 3). It always has correct return type. Bad: you should be careful inside the implementation.
const doQuery = <T extends Query<any>>({ query }: { query: T }): T extends Query<infer V> ? V : never => {
const result = query.doTheQuery()
const a = 6 + result; // No error, because "result" on this step inferred as "any"
return result;
}
And finally, here is my favorite one:
type DoQueryResult<T extends Query<A | B | C>> = T extends Query<infer V> ? V : never;
const doQuery = <T extends Query<A | B | C>>({ query }: { query: T }): DoQueryResult<T> => {
return result as DoQueryResult<T>;
}
It has all benefits of the previous two solutions and just one drawback: you should typecast return statement in implementation (result as DoQueryResult<T>)
const doQuery = <T extends Query<A | B | C>>({ query }: { query: T }): DoQueryResult<T> => {
const result = query.doTheQuery()
const a = 6 + result; // It errors
return result as DoQueryResult<T>; // It infers result as A | B | C and we need to typecast it
}