CuriousGeorge
CuriousGeorge

Reputation: 569

Type Guards for child interface with optional parameter

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

Answers (1)

jcalz
jcalz

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!

Playground link to code

Upvotes: 1

Related Questions