Reputation: 569
I am trying to write type guards for the following three interfaces:
export interface DeleteBatchAction {
documentReference: firebase.firestore.DocumentReference<any>;
}
export interface UpdateBatchAction extends DeleteBatchAction {
data: firebase.firestore.UpdateData;
}
export interface SetBatchAction extends UpdateBatchAction {
options?: firebase.firestore.SetOptions;
}
export type BatchAction = DeleteBatchAction | UpdateBatchAction | SetBatchAction;
Each of the interfaces extends the last. The biggest issue here is that SetBatchAction
only has an optional parameter distinguishing it from UpdateBatchAction
.
1) Is there a way to write a type guard so that a variable of type BatchAction
can be resolved into SetBatchAction
vs. UpdateBatchAction
reliably?
2) Do I need to invoke the type guards in a specific order to ensure that I have the correct type? (An object of type UpdateBatchAction
would pass the isDeleteBatchAction
type guard if that is checked first?).
Upvotes: 0
Views: 469
Reputation: 328292
The conventional way to represent this sort of thing in TypeScript is to use a discriminated union. That's a union type where each member of the union has a property in common, called the discriminant, that is used to tell the different members of the union apart. The type of this property should be something like a string literal type or a numeric literal type. Here's a way of representing your BatchAction
type as a discriminated union:
export interface DeleteBatchAction {
type: "DeleteBatchAction"
documentReference: firebase.firestore.DocumentReference<any>;
}
export interface UpdateBatchAction {
type: "UpdateBatchAction";
documentReference: firebase.firestore.DocumentReference<any>;
data: firebase.firestore.UpdateData;
}
export interface SetBatchAction {
type: "SetBatchAction";
documentReference: firebase.firestore.DocumentReference<any>;
data: firebase.firestore.UpdateData;
options?: firebase.firestore.SetOptions;
}
export type BatchAction = DeleteBatchAction | UpdateBatchAction | SetBatchAction;
Each member of that union has a type
property of a particular string literal type. The compiler will then let you switch
on or otherwise type guard against the type
property, and it will automatically narrow a BatchAction
to one of its union members:
function foo(x: BatchAction) {
switch (x.type) {
case "DeleteBatchAction": {
x.documentReference; // okay
return 1;
}
case "UpdateBatchAction": {
x.documentReference; // okay
x.data; // okay
return 2;
}
case "SetBatchAction": {
x.documentReference; // okay
x.data; // okay
x.options; // okay
return 3;
}
}
}
If you prefer to reuse your interfaces and use the inheritance pattern you can still do it, but you will need to add a type
-like property to SetBatchAction
and check the types in a particular order to reliably tell the union members apart. For example, each SetBatchAction
should have a setBatchAction
property whose type is true
. Like this:
export interface DeleteBatchAction {
documentReference: firebase.firestore.DocumentReference<any>;
}
export interface UpdateBatchAction extends DeleteBatchAction {
data: firebase.firestore.UpdateData;
}
export interface SetBatchAction extends UpdateBatchAction {
setBatchAction: true; // add this
options?: firebase.firestore.SetOptions;
}
export type BatchAction = DeleteBatchAction | UpdateBatchAction | SetBatchAction;
And then you can guard by checking first for setBatchAction
, and then for data
:
function foo(x: BatchAction) {
if ("setBatchAction" in x) {
x // SetBatchAction
x.options
x.data
x.documentReference
return 3;
} else if ("data" in x) {
x // UpdateBatchAction
x.data
x.documentReference
return 2;
} else {
x // DeleteBatchAction
x.documentReference
return 1;
}
}
This works just as well, but the implementation of "foo"
here is a little easier to get wrong and (in my opinion) harder to understand than the one using the discriminated union.
Okay, hope that helps; good luck!
Upvotes: 1