Search code examples
typescript

trying to narrow/determine type of argument for a method in a dynamically-determined class expecting a specific type


I have two flavors of objects. Their TypeScript interfaces extend a common base. I have another class for each of these that defines the same, state-identifying methods (we'll call these the "state classes"), and a function to determine which of the two state classes to use based on a given object's flavor.

The state classes are used as global services. For matters of efficiency, I'm trying to avoid instantiating them for each object. In other words, I want to pass in an object of that state class's expected, specific flavor/interface/type.

class ItemStateFlavorA {
  isActive(item: IItemFlavorA) {
    return true;
  }
}

class ItemStateFlavorB {
  isActive(item: IItemFlavorB) {
    return true;
  }
}

const ItemServiceFlavorA = new ItemStateFlavorA();
const ItemServiceFlavorB = new ItemStateFlavorB();

function getServiceForFlavor(item: IItem) {
  switch(item.flavor) {
    case 'A':
      return ItemServiceFlavorA;
    case 'B':
      return ItemServiceFlavorB;
    default:
      throw new Error('Unknown flavor');
  }
}

I run into trouble when trying to pass to those methods an object that may be IItemFlavorA or IItemFlavorB. Specifically,

Argument of type 'IItemFlavorA | IItemFlavorB' is not assignable to parameter of type 'IItemFlavorA & IItemFlavorB'.(2345)

I've tried using type predicates in each state class, but that didn't solve the problem

class ItemStateFlavorA {
  assertFlavor(item: IItem): item is IItemFlavorA {
    return item.flavor === 'A';
  }

  // ...
}


function archiveItem(item: IItem) {
  const itemFlavorService = getServiceForFlavor(item);

  if(itemFlavorService.assertFlavor(item) && itemFlavorService.isActive(item)) {
    console.log('archive the item')
  }
}

I know I can work around this, but I'm trying to plan for more than two flavors. I'm trying to avoid:

  • having a single, generically-typed class with switches in every state method
  • casting item as either/any type
  • using the base interface/type in the function definitions (i.e., every flavor's state methods expect IItem), because the objects may have very different props used to, in this example, define "isActive"

How can I help TypeScript infer that item is an IItemFlavorA or IItemFlavorB, not IItemFlavorA | IItemFlavorB?

full example at TS Playground


Solution

  • The problem with your archiveItem() function is that itemFlavorService is of the union type ItemServiceFlavorA | ItemServiceFlavorB, and it's being used multiple times in a single block of code. TypeScript is unable to deal with correlated unions, as described in microsoft/TypeScript#30581. Each utterance of itemFlavorService is treated as being independently of type ItemServiceFlavorA | ItemServiceFlavorB. It can't "hypothetically" narrow itemFlavorService to each member of the union, make sure each one works, and then conclude that the whole thing works. If you want that behavior you need to use multiple (redundant) blocks of code and do the narrowing yourself.

    If you have to use a single block of code where the same union-typed thing is being referred to multiple times, the only support TypeScript has is to switch from unions to generics. There is a "standard" refactoring from unions to generics described in microsoft/TypeScript#47109. But that can't be done directly here without refactoring your types significantly, and it requires you use TypeScript 4.6 and above. If you actually have a dependency on TypeScript 4.2 as your playground link implies, then you need another approach.


    I'd say that what you're looking for is something like this type:

    interface IItemStateFlavor<T extends IItem> {
      assertFlavor(item: IItem): item is T;
      isActive(item: T): boolean;
    }
    

    And then you want itemFlavorService to be an IItemStateFlavor<T> for some T you don't actually care about. All you care about is that it's the same T for each utterance of itemFlavorService. The concept of "some" generic is known as an existentially quantified generic type. Existential generics are kind of like unions, but they allow you to abstract over operations in a way unions can't.

    But TypeScript doesn't directly support existential generics (there's a longstanding open feature request at microsoft/TypeScript#14466 for this). Neither do most languages with generics. If this were possible, you might be able to write

    // don't do this, not possible
    type SomeItemStateFlavor = <exists T extends IItem> IItemStateFlavor<T>
    

    and then use it. But it isn't possible, so you can't. TypeScript's generics, like that in most languages with generics, are universally quantified (meaning "for all" instead of "for some"). It is possible to emulate existential generics with universal generics by using a Promise-like inversion of control. If you have a generic like F<T> and want to have <exists T> F<T>, you can use <R>(cb: <T>(ft: F<T>) => R) => R instead. It's like a little container around an F<T> that hides T. It says "you give me a generic callback that accepts an F<T> for any T, and I'll run that callback on my hidden F<T> where only I know what T is, and then I'll give you the result". That's just as good as having the F<T> yourself, except that you are forbidden from doing anything where you need to know what T is.


    So, SomeItemStateFlavor can be defined like

    type SomeItemStateFlavor =
      <R>(cb: <T extends IItem>(i: IItemStateFlavor<T>) => R) => R;
    

    And we can write a little helper function to wrap an IItemStateFlavor<T> so it can be used existentially:

    const someItemStateFlavor =
      <T extends IItem>(i: IItemStateFlavor<T>): SomeItemStateFlavor => cb => cb(i);
    

    Now we can rewrite getServiceForFlavor() to return SomeItemStateFlavor:

    function getSomeServiceForFlavor(item: IItem): SomeItemStateFlavor {
      switch (item.flavor) {
        case 'A':
          return someItemStateFlavor(ItemServiceFlavorA);
        case 'B':
          return someItemStateFlavor(ItemServiceFlavorB);
        default:
          throw new Error('Unknown type');
      }
    }
    

    And finally you can write archiveItem():

    function archiveItem(item: IItem) {
      const someItemFlavorService = getSomeServiceForFlavor(item);
      someItemFlavorService(itemFlavorService => {
        if (itemFlavorService.assertFlavor(item)) {
          if (itemFlavorService.isActive(item)) {
            console.log('archive the item')
          }
        }
      })
    }
    

    Notice that inside the callback, the type of itemFlavorService is inferred to be IItemStateFlavor<T> for some anonymous T. When you write itemFlavorService.assertFlavor(item), the type of item is narrowed from IItem to this anonymous T, and then it is seen as a suitable argument for itemFlavorService.isActive(). So this all works as intended.

    It's just a lot of hoop jumping, though, and likely to confuse people.


    Now, I assume that in the actual use case the getServiceForFlavor() return type is going to have more functionality than just assertFlavor() and isActive(). Because otherwise, all you can realistically do is call assertFlavor() followed by isActive() if it's true. And if that's all you can do, you might as well just provide that functionality directly, as a single method all state flavor things share:

    class ItemStateFlavorA {
      declare assertFlavor(item: IItem): item is IItemFlavorA;
      delcare isActive(item: IItemFlavorA): boolean; 
    
      assertAndIsActive(item: IItem) {
        return this.assertFlavor(item) ?
          { asserted: true as const, active: this.isActive(item) } :
          { asserted: false as const };
      }      
    }
    
    class ItemStateFlavorB {
      declare assertFlavor(item: IItem): item is IItemFlavorB;
      declare isActive(item: IItemFlavorB): boolean;         
      assertAndIsActive(item: IItem) {
        return this.assertFlavor(item) ?
          { asserted: true as const, active: this.isActive(item) } :
          { asserted: false as const };
      }    
    }
    

    And then you just need

    function archiveItem2(item: IItem) {
      const itemFlavorService = getServiceForFlavor(item);
      if (itemFlavorService.assertAndIsActive(item).active) {
        console.log('archive the item')
      } 
    }
    

    On the other hand, if you can upgrade to a newer TypeScript and are willing to refactor so that you actually encode in the type system which service goes with which flavor, then you can use the microsoft/TypeScript#47109 refactoring, give up on "asserting" anything, and just use generics. This might be off topic for the question so I won't explain in detail how this works; see microsoft/TypeScript#47109 for more information:

    interface IItemBase {
      name: string;
      desc: string;
    }
    
    interface FlavorMap {
      A: { start: string, end: string }
      B: { deleted: boolean }
    }
    
    type IItem<K extends keyof FlavorMap = keyof FlavorMap> =
      { [P in K]: IItemBase & { flavor: P } & FlavorMap[P] }[K]
    
    class ItemStateFlavorA {
      isActive(item: IItem<"A">) {
        return true;
      }
    }
    
    class ItemStateFlavorB {
      isActive(item: IItem<"B">) {
        // true if item is not deleted
        return true;
      }
    }
    
    function getServiceForFlavor<K extends keyof FlavorMap>(item: IItem<K>) {
      const m: { [K in keyof FlavorMap]: { isActive(item: IItem<K>): boolean } } = {
        get A() { return new ItemStateFlavorA() },
        get B() { return new ItemStateFlavorB() }
      };
      return m[item.flavor]
    }
    
    function archiveItem<K extends keyof FlavorMap>(item: IItem<K>) {
      const itemFlavorService = getServiceForFlavor(item);
      if (itemFlavorService.isActive(item)) { // okay
        console.log('archive the item');
      }
    }
    

    Basically now getServiceForFlavor() and archiveItem() are generic in K which is either "A" or "B", and the types are set up so that TypeScript can see that getServiceForFlavor(item).isActive(item) is appropriate no matter which flavor item turns out to be.

    Playground link to code