Better auto-completion from extracted union types

3 weeks ago 11
ARTICLE AD BOX

I think the problem here is that unions just don't work the way you want. Effectively you want {_type: "foo", name?: string, fooThing?: number} | {_type: "bar", name?: string, barThing?: Date} to behave like the combined type {_type: "foo" | "bar", name?: string} when it comes to IntelliSense auto-suggestions and for excess property checking. But it doesn't behave that way. Neither the fooThing nor the barThing property is considered to be an "excess" property, since it is present somewhere in the union. There's a longstanding feature request at microsoft/TypeScript#20863 to complain about properties from the wrong part of a union. It's not implemented and might never be. Even if it were implemented, you might not get the sort of auto-suggestions you want. So this is currently impossible.


You can get a little closer by using the techniques from Why does A | B allow a combination of both, and how can I prevent it? to make cross-union properties harder to include, by marking them as optional properties of type never, like

type FooPatch = { _type: 'foo'; name?: string; fooThing?: number; barThing?: never; }; type BarPatch = { _type: 'bar', name?: string; barThing?: Date; fooThing?: never; } type Patch = FooPatch | BarPatch

and this at least gives you an error if you try to write fooThing or barThing in a place where _type is not known to be exclusively "foo" or "bar":

const test: Patch = { // error! _type: Math.random() > 0.5 ? 'foo' : 'bar', name: "", fooThing: 1 };

but it doesn't place the error on the "excess" property, and the error message is an artifact of the support for treating single objects as multiple union members added in TypeScript 3.5, and complains about _type not being right, instead of about fooThing. And you still don't get the kind of IntelliSense you want.


So this is probably not the way you want to go. You might decide to write different types, like

type FooPatch = { _type: 'foo'; name?: string; fooThing?: number; }; type BarPatch = { _type: 'bar', name?: string; barThing?: Date; } type EitherPatch = FooPatch | BarPatch; type NonDistributiveId<T, K extends keyof T = keyof T> = { [P in K]: T[P] } type BothPatch = NonDistributiveId<EitherPatch> /* type BothPatch = { _type: "foo" | "bar"; name?: string | undefined; } */

so you use EitherPatch if you have a known _type, or a BothPatch if you don't:

const test: BothPatch = { _type: Math.random() > 0.5 ? 'foo' : 'bar', name: 'yay, name shows up', fooThing: 1, // error };

And anywhere you want to accept a Patch, you use the EitherPatch union, because BothPatch is a valid subtype of it:

const test2: EitherPatch = test; // okay

You might even be able to collapse that behavior into a helper function so that the person writing the object literal is not required to keep track of whether they want either-or-both:

const makePatch = <T extends EitherPatch["_type"]>( t: { _type: T } & NonDistributiveId<Extract<EitherPatch, { _type: T }>> ): NonDistributiveId<Extract<EitherPatch, { _type: T }>> => t; const bar = makePatch({ _type: "bar", name: "", barThing: new Date() }); // okay const foo = makePatch({ _type: "foo", name: "", fooThing: 123 }); // okay const either = makePatch({ _type: fooOrBar, name: "", fooThing: 123 }); // error!

It's not perfect, but it's about as close as I can imagine getting to the behavior you're asking for, in the current version of TypeScript.

Playground link to code

Read Entire Article