ARTICLE AD BOX
Given your implementation of changeApiVersion, TypeScript has inferred the function's call signature to be
const changeApiVersion: (apiBodyInput: InputA | InputB, targetVersion: "3" | "4") => InputA | InputBAnd that's not wrong, but it doesn't give you the intended behavior where the function return type actually depends on the value passed in as targetVersion. If you want a function's return type (and not just the return value) to depend on inputs, then you will need to make that function either an overloaded function, or a generic function.
TypeScript will never infer an overloaded or generic call signature from an implementation. You will need to do it manually via annotations. Then once you do that, you need to figure out how to convince TypeScript to allow your implementation to correspond to its call signature(s). This convincing could just be the equivalent of a type assertion where you just tell TypeScript you've done it right and it believes you, or it could be jumping through a series of hoops where TypeScript's type checker actually concludes that you've done it right. Assertions are much easier.
Anyway, you need to either overload your function or make it generic. For ease of discussion I'll define these helper types:
type Input = InputA | InputB type Version = Input["version"];You could overload your function, where you list out all the ways you'd like the function to be called, and then implement it separately. For this example that looks like
declare function changeApiVersion(apiBodyInput: Input, targetVersion: '3'): InputB; declare function changeApiVersion(apiBodyInput: Input, targetVersion: '4'): InputA;I haven't done the implementation here. This is just what callers see. Anyway, you can verify that this now works as intended:
const myFoo = changeApiVersion({ version: '3', bar: '' }, '4').foo; // okayOverloads work well enough as long as you can easily write out all the ways you want callers to use your function. In this case it seems like the return type really only depends on the type of targetVersion, so you'd have to write one call signature per member of your union. That scales okay, but might not be what you want to do.
Or you can make it generic, where you express things in terms of generic type parameters and relationships between them. You'd need to write a mapping from input types to output types, like this:
interface InputMap { "4": InputA; "3": InputB; } declare function changeApiVersion<T extends Version>( apiBodyInput: Input, targetVersion: T): InputMap[T];This captures your intent in a single call signature that depends on the type parameter T. And I'm using the indexed access type to look up T in the InputMap mapping type. This is easier to maintain that a set of overloads. You might object that now InputMap has to grow over time, but I just wrote InputMap out like that to make it obvious what it does. You can actually compute it from Input like
type InputMap = { [T in Input as T["version"]]: T };And now as long as you maintain your Input union, the definition of InputMap and therefore the type of changeApiVersion will keep up with it. Again, you can verify that this works for callers:
const myFoo = changeApiVersion({ version: '3', bar: '' }, '4').foo; // okaySo great, callers will be happy. The challenging part is to make TypeScript accept your function implementation without giving up too much type safety. Overloads are usually written as function statements and their implementations are very loosely checked. It's easy to write
function changeApiVersion(apiBodyInput: Input, targetVersion: '3'): InputB; function changeApiVersion(apiBodyInput: Input, targetVersion: '4'): InputA; function changeApiVersion(apiBodyInput: Input, targetVersion: Version) { switch (targetVersion) { case '3': return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput; case '4': return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput; } }and get it to compile with no compiler errors. Great, no problem, right? Well, except I messed up and I swapped the implementation of case '3' with case '4'. Oops! So if you write overloads you need to be very careful to check your implementation because TypeScript mostly cannot.
If you want your overloads to be function expressions instead of statements then you will need actual type assertions, because now TypeScript will complain, even if you've written it correctly:
interface ChangeApiVersion { (apiBodyInput: Input, targetVersion: '3'): InputB; (apiBodyInput: Input, targetVersion: '4'): InputA; } const changeApiVersion: ChangeApiVersion = (apiBodyInput: Input, targetVersion: Version) => { // ~~~~~~~~~~~~~~~~ ERROR! switch (targetVersion) { case '4': return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput; case '3': return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput; } }Assertions fix the compiler warning but don't help the safety issue:
const changeApiVersion: ChangeApiVersion = ((apiBodyInput: Input, targetVersion: Version) => { switch (targetVersion) { case '4': return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput; case '3': return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput; } }) as ChangeApiVersionFor generics you end up in pretty much the same boat. TypeScript can't easily verify that your implementation in terms of switch/case statement will ever satisfy a generic call signature, so you have the same errors and assertions:
function changeApiVersion<T extends Version>(apiBodyInput: Input, targetVersion: T): InputMap[T] { switch (targetVersion) { case '4': return (apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput); // ERROR! case '3': return (apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput); // ERROR! } } function changeApiVersion<T extends Version>(apiBodyInput: Input, targetVersion: T): InputMap[T] { switch (targetVersion) { case '4': return (apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput) as InputMap[T]; case '3': return (apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput) as InputMap[T]; } }That's about as far as I can go here. It is possible to refactor changeApiVersion's implementation, as well as its signature, to make TypeScript actually pay attention to what it's doing in such a way as to complain if you write it wrong but not if you write it right. But it involves a lot of craziness. Here's what the code looks like:
interface InputVersions { '3': { bar: string }, '4': { foo: string } } type Version = keyof InputVersions; type Input<K extends Version = Version> = { [P in K]: { version: P } & InputVersions[P] }[K] type InputA = Input<'4'> type InputB = Input<'3'> const convert3to4 = (apiBodyInput: InputB): InputA => { return { version: '4', foo: apiBodyInput.bar }; }; const convert4to3 = (apiBodyInput: InputA): InputB => { return { version: '3', bar: apiBodyInput.foo }; }; type ConverterFn<KF extends Version, KT extends Version> = (apiBodyInput: Input<KF>) => Input<KT> function changeApiVersion<KF extends Version, KT extends Version>( apiBodyInput: Input<KF>, targetVersion: KT ): Input<KT> { const convertFromMapToMap: { [F in Version]: { [T in Version]: ConverterFn<F, T> } } = { "3": { "3": x => x, "4": convert3to4 }, "4": { "3": convert4to3, "4": x => x } } const convertFromToMap: { [T in Version]: ConverterFn<KF, T> } = convertFromMapToMap[apiBodyInput.version]; const convertFromTo: ConverterFn<KF, KT> = convertFromToMap[targetVersion]; return convertFromTo(apiBodyInput); } changeApiVersion({ version: '3', bar: '' }, '4').foo; // okayThe definition of convertFromMapToMap captures your control-flow without writing it as switches or conditionals. You can see that if you swap cases around you'll get errors. But why is it written this way? Well, that's probably out of scope here. If you're interested, you can read microsoft/TypeScript#30581 to see the basic issue TypeScript has with so-called correlated unions, and you can then read microsoft/TypeScript#47109 to see the only supported way to deal with them, involving refactoring away from conditional control flow and toward indexing into mapped types. If you really really care about implementation type safety, this refactoring might be worthwhile. Otherwise, you probably want to just use assertions of the like and move on.
