I have a union of disjoint types, and I have a type that is parameterized on those disjoint types. I want the second type to accept only specific members of the union, ie. it should prevent mixing-and-matching between the different subtypes.
Is there a way to do this without having to list each of the different subtypes individually in a new union?
Example:
type Wobble = {|
type: 0,
wobbliness: number,
|};
type Dohicky = {|
type: 1,
use: string,
|};
type Doodle = {|
type: 2,
bitmap: string,
|};
type Thing =
| Wobble
| Dohicky
| Doodle;
type InflightRequest<T: $Subtype<Thing>> = {|
requestType: $PropertyType<T, 'type'>,
resolve: (thing: T) => void,
|};
// I want this array to only accept matching requestType/resolve
const arrayOfRequests: Array<InflightRequest<$Subtype<Thing>>> = [];
arrayOfRequests.push({
requestType: 0,
resolve: (thing: Dohicky) => {}, // should error
});
// This works, but I'd prefer not to have to write everything out
type InflightThingRequest =
| InflightRequest<Wobble>
| InflightRequest<Dohicky>
| InflightRequest<Doodle>;
const arrayOfThingRequests: Array<InflightThingRequest> = [];
arrayOfThingRequests.push({
requestType: 0,
resolve: (thing: Dohicky) => {}, // does error
});
You can’t “map” over the union directly, but you can map over a helper
type in a readable and reusable way:
type Wobble = {| type: 0, wobbliness: number |};
type Dohicky = {| type: 1, use: string |};
type Doodle = {| type: 2, bitmap: string |};
// Utility type; never instantiated.
type Things = {
Wobble: Wobble,
Dohicky: Dohicky,
Doodle: Doodle,
};
type Thing = $Values<Things>;
type InflightRequestT<T: Thing> = {|
requestType: $PropertyType<T, 'type'>,
resolve: (thing: T) => void,
|};
type InflightRequests = $ObjMap<Things, <T>(T) => InflightRequestT<T>>;
type InflightRequest = $Values<InflightRequests>;
(See: $ObjMap docs.)
Flow type-at-pos says:
type InflightRequest =
| InflightRequestT<Wobble>
| InflightRequestT<Doodle>
| InflightRequestT<Dohicky>;
which was what was wanted.
This appears to correctly admit correct programs and correctly raise an
error on incorrect programs, but in the case of an incorrect program the
errors can be unhelpful compared to the version in which you have
explicitly written out the InflightRequest type. For instance, writing
const requests: InflightRequest[] = [
{ requestType: 1, resolve: (thing: Dohicky) => {} },
];
correctly typechecks, and
const requests: InflightRequest[] = [
{ requestType: 2, resolve: (thing: Dohicky) => {} },
];
correctly fails to typecheck, while
const requests: InflightRequest[] = [
{ requestType: 0, resolve: (thing: Dohicky) => {} },
{ requestType: 1, resolve: (thing: Dohicky) => {} },
];
also correctly fails to typecheck, but raises errors for
_both_ elements of the array rather than just the problematic one.
A benefit of this approach is that you can reuse the Things type for
other $ObjMaps (say, a CompletedRequest type).
(Aside: prefer type X<T: Thing>, not type X<T: $Subtype<Thing>>. The
constraint is already a subtyping constraint. The two may be equivalent
here, but $Subtype in general is unsafe (and poorly documented), and
should be avoided.)
You're a type wizard! Thank you for your service. I figured it'd have to do with $ObjMap, but never considered the idea of a utility type. Will definitely be adding to the mental tool chest.
I am running into some issues trying to actually call the "resolve" function. This code errors using both the manually typed method I used in the OP, and the $ObjMap method:
function resolveThing(thing: Thing) {
for (let request of requests) {
if (thing.type === request.requestType) {
request.resolve(thing);
}
}
}
With the manual either definition (see edit below), it's possible to placate the typechecker with unnecessary runtime checks, but for some reason this doesn't work with the $ObjMap method:
function resolveThing(thing: Thing) {
for (let request of requests) {
if (thing.type === 0 && request.requestType === 0) {
request.resolve(thing);
} else if (thing.type === 1 && request.requestType === 1) {
request.resolve(thing);
} else if (thing.type === 2 && request.requestType === 2) {
request.resolve(thing);
}
}
}
Thanks again for your help!
EDIT
Managed to get the disjoint-ness passed down using the $ObjMap method by just adding an $Exact to the function type return:
type InflightRequests = $ObjMap<Things, <T>(T) => InflightRequestT<$Exact<T>>>;
So now the "unnecessary runtime checks" code above works for the $ObjMap method, but unfortunately both versions still fail the simpler function (without runtime checks) at the top of this comment.
You're a type wizard! Thank you for your service. I figured it'd have
to do with$ObjMap, but never considered the idea of a utility type.
Will definitely be adding to the mental tool chest.
Happy to help. It’s a good trick to have in hand.
I am running into some issues trying to actually call the "resolve"
function. This code errors
Yeah, this is a really annoying behavior of Flow. The simplest repro
that I’ve been able to find is
// @flow
type A = {| +type: "A" |};
type B = {| +type: "B", +value: number |};
type O = A | B;
function f(o: O): O {
return {...o}; // fails!
}
function g(o: O): O {
return o.type === "A" ? {...o} : {...o}; // works...
}
but for some reason this doesn't work with the
$ObjMapmethod:
Ugh. That’s strange, and definitely a bug. I don’t have enough
visibility into Flow’s internals to be able to diagnose it, sorry.
(edit: see above and below comments for solution)
Managed to get the disjoint-ness passed down using the
$ObjMap
method by just adding an$Exactto the function type return:
Good catch. This is #5853, then. Glad that you found a solution.
I've got it working in my project now, and all of the remaining issues are covered by #3350 and #5853, so I'm gonna go ahead and close this issue. Thanks again for your help @wchargin!