TypeScript's compiler isn't able to verify or infer the conditional relationship between function input and output typings from its implementation. It can perform control flow analysis inside the function implementation to narrow return types based on type guard tests, but the return type for the whole function is only inferred to be or verified against the union of all returned types.
So the best the compiler would be able to figure out for your intended function implementation would be this:
function get(opts: Options & ({ type: "person" } | { type: "car" })): Person | Car {
switch (opts.type) {
case "person": return { name: "john" }
case "car": return { wheels: 4 }
}
}
The return type is Person | Car. The relationship between opts.type and the particular member of Person | Car returned is lost. You can use overloads to tell the compiler about the relationship, but this is equivalent to a type assertion. The compiler will only complain if the relationship you are asserting is completely unrelated to what it can verify:
function getOops(opts: Options & { type: "person" }): Person
function getOops(opts: Options & { type: "car" }): Car // error!
function getOops(opts: Options & ({ type: "person" } | { type: "car" })) {
switch (opts.type) {
case "person": return { name: "john" }
case "car": return { wheels: "round" } // this isn't a Car or a Person
}
}
And so overloads are unsound in the same way that type assertions are unsound: you can use them to lie to the compiler.
There is a (longstanding) open issue asking for the kind of type checking you want: microsoft/TypeScript#10765. It's not clear how to improve this without very negatively affecting performance. Furthermore, people rely on this unsoundness and often use overload signatures as an alternative to type assertions, so fixing this would end up being a huge breaking change for lots of code. For the foreseeable future, you should view overload function statement implementations as being a place where type safety needs to be guaranteed by the implementer and not by the compiler.
Related aside: overloads are an older feature of TypeScript that could possibly be replaced with generics and conditional types. Instead of { (a: string) => number; (a: number) => string; }, you can have the signature <T extends string | number>(a: T) => T extends string ? number : string;. But generic conditional types have nearly the same issue as with overloads: the compiler can't verify the relationship in the implementation. The only difference is that with conditional types the compiler just complains all the time and you need a type assertion, whereas with overloads the compiler is largely silent:
function getGenericConditional<K extends "person" | "car">(
opts: Options & { type: K }
): K extends "person" ? Person : Car {
switch (opts.type) {
// need to assert both of these
case "person": return { name: "john" } as K extends "person" ? Person : Car
case "car": return { wheels: 4 } as K extends "person" ? Person : Car
}
throw new Error(); // compiler can't tell that this is exhaustive
}
And there's an open issue here asking for this to be improved also: microsoft/TypeScript#33912, and again, it's not clear how to do this efficiently.
So, are there any other options? One thing the compiler is able to verify is that if you have a value of type T and a key of type K that extends keyof T, that indexing into that property produces a value of type T[K]. If you can make your generic thing key-like, (such as "person" and "car"), then you can reframe the relationship from input to output as an indexed access:
type Mapping = {
person: Person,
car: Car
}
function getIndexed<K extends "person" | "car">(opts: { type: K }): Mapping[K] {
return ({ person: { name: "john" }, car: { name: "john" } })[opts.type]; // error!
}
And if you don't want to make the Mapping object up front, you can use getters to defer creating the returned value until you need it:
function getIndexedDeferred<K extends "person" | "car">(opts: { type: K }): Mapping[K] {
const map: Mapping = {
get person() { return { name: "john" } },
get car() { return { name: "john" } } // error!
}
return map[opts.type];
}
So the above is type safe and the compiler knows it, but it's not idiomatic JS so you might prefer to just take care with your overloads.
Okay, hope that helps; good luck!
Playground link to code