Search code examples
typescripttypescript-genericstypescript-types

Discrimination of a union of types using the type of a correlated argument variable


I have the following types and an enum:

enum TableNames {
  clients = "clients",
  products = "products"
}

interface IClient {
  client: string;
  location: string;
  type: string;
}
interface IProduct {
  product: string;
  family: string;
  subfamily: string;
}

type TSelector<T> = {
  [K in keyof T]: T[K][];
};

type TClientSelector = TSelector<IClient>;
type TProductSelector = TSelector<IProduct>;

These two classes initialize an object based on TClientSelector and TProductSelector.

export class BaseClientSelector implements TClientSelector {
  client: string[] = [];
  type: string[] = [];
  location: string[] = [];
}

export class BaseProductSelector implements TProductSelector {
  product: string[] = [];
  family: string[] = [];
  subfamily: string[] = [];
}

Factory function that uses variable name to dispatch an object with type TClientSelector or TProductSelector.

const getEmptySelectorObject = (name: TableNames): TClientSelector | TProductSelector => {
  if (name === TableNames.clients) {
    return new BaseClientSelector();
  } else {
    return new BaseProductSelector();
  }
};

And here lies the problem. Consider the following function:

export const getSelectorData = (
  arrayOfObj: (IClient | IProduct)[],
  name: TableNames
): TClientSelector | TProductSelector => {
  const selectorData: TClientSelector | TProductSelector = getEmptySelectorObject(name);

  for (const col in selectorData) {
    selectorData[col] = Array.from(
//  ^^^^^^^^^^^^^^^^^ TS7053
      new Set(arrayOfObj.map((obj: IClient | IProduct) => obj[col]))
//                                                        ^^^^^^^^ TS7053
    ).sort((a, b) => String(a).localeCompare(String(b)));
  }

  return selectorData;
};

I know that if name has type TableNames.clients, then selectorData has to be of type TClientSelector. But, since TypeScript cannot know which type will selectorData have because I do not know how to express this relationship to it then, when iterating over the keys of selectorData, it defaults to assigning col to type string. This prompts the compiler to tell me that TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'TSelector<IClient> | TSelector<IProduct>'.   No index signature with a parameter of type 'string' was found on type 'TSelector<IClient> | TSelector<IProduct>'.

Something similar happens inside the map where obj can be either IClient or IProduct so col cannot be used to index it.

Solutions I have tried that do not convince me:

  • Code repetition with a type guard.
  • Adding explicit anys.
  • Function overloading (one for TClientSelector and another for TProductSelector.

I am fairly certain that this can be solved using generics but I cannot wrap my head around them.

I found this answer by jcalz https://stackoverflow.com/a/72294288 that uses a distributive object type and something tells me this could be the answer but I cannot adapt that solution to my problem.


Solution

  • Right now even the call side of getSelectorData() is a problem, since the correlation between the two arguments is not represented. Your call signature looks like:

    declare const getSelectorData: (
      arrayOfObj: (IClient | IProduct)[], name: TableNames
    ) => TClientSelector | TProductSelector;
    

    And so it allows this:

    getSelectorData([{ client: "a", location: "b", type: "c" }],
      TableNames.products
    ) // no error, but there should be.
    

    You could enforce that correlation with a discriminated union of rest parameter tuples:

    declare const getSelectorData: (
      ...[arrayOfObj, name]:
        [IClient[], TableNames.clients] |
        [IProduct[], TableNames.products]
    ) => TClientSelector | TProductSelector;
    
    getSelectorData(
      [{ client: "a", location: "b", type: "c" }], // error!
      TableNames.products
    );
    getSelectorData(
      [{ client: "a", location: "b", type: "c" }], // okay
      TableNames.clients
    )
    

    But this only helps from the caller's side.


    The implementation still has errors, and that's because TypeScript doesn't support "correlated union" types very well, where a single block of code would need to be analyzed once for each possible narrowing of some union-typed expression, as discussed in microsoft/TypeScript#30581.

    In your function implementation, you'd want the compiler to notice that things are fine if the function arguments are of type [IClient[], TableNames.clients] and they're also fine if they are of type [IProduct[], TableNames.products], so they are fine overall. But TypeScript doesn't do any sort of "distributive" control flow analysis (not even on an "opt-in" basis as suggested in microsoft/TypeScript#25051). What happens instead is that the compiler treats each union-typed value as independent, and so even if the call signature has prevented from passing in [IClient[], TableNames.products], the compiler doesn't realize that in the implementation. And so it complains.


    The official recommended way to fix things like this is described in microsoft/TypeScript#47109. The idea is to switch away from unions and towards [generics]https://www.typescriptlang.org/docs/handbook/2/generics.html) that are constrained to the keys of some base interface. And then you'd have to rewrite all your types and operations in terms of that base interface, mapped types over that interface, and generic indexes into them.

    But doing so is often a significant refactoring and can be hard to follow. If you want expedience, you should just assert or use the any type after double checking that your implementation is good. The cure might be worse than the disease.

    You be the judge:


    In your case I'd refactor as follows. Here's the basic interface representing the relationship you want to abstract over:

    interface TableMap {
      [TableNames.clients]: IClient,
      [TableNames.products]: IProduct
    }
    

    Now you can rewrite TSelector as generic over the key of TableMap, like this:

    type ArrayProps<T> = { [K in keyof T]: T[K][] }
    type TSelectorMap<K extends TableNames> = { [P in K]: ArrayProps<TableMap[P]> }
    type TSelector<K extends TableNames> = TSelectorMap<K>[K]
    

    The utility type ArrayProps<T> just makes it easy to represent what you want TSelector<K> to be. Note that TSelector<K> is indexing into a mapped type with all its keys, which makes it a distributive object type as coined in ms/TS#47109. I named TSelectorMap<K> to be just the mapped type part, since it will be useful later.

    Your TClientSelector and TProductSelector types can be slightly rewritten as:

    type TClientSelector = TSelector<TableNames.clients>;
    type TProductSelector = TSelector<TableNames.products>;
    

    and you can verify they are the same as before.

    Now, in order for this to work, your getEmptySelectorObject() function must be generic the same way. If it returns a union, then we will be stuck. Since the return type will be TSelector<K>, an index into a TSelectorMap<K> with a key of type K, then the way to convince the compiler this works is to actually make a value of type TSelectorMap<K> and index into it. Here's a TSelectorMap<TableNames> (a subtype of TSelectorMap<K> for any K extends TableNames):

    const baseSelectors: TSelectorMap<TableNames> = {
      get [TableNames.clients]() { return new BaseClientSelector() },
      get [TableNames.products]() { return new BaseProductSelector() }
    }
    

    Note that I'm using getter methods so that the actual code does not run until someone tries to access the property. And now the getEmptySelectorObject() function implementation just needs to look up name in baseSelectors:

    const getEmptySelectorObject = <K extends TableNames>(name: K): TSelector<K> => {
      const _baseSelectors: TSelectorMap<K> = baseSelectors;
      return _baseSelectors[name];
    };
    

    Well, almost. The compiler can't "see" that baseSelectors[name] is of type TSelectorMap<K>[K]. Instead it only "sees" TSelectorMap<TableNames>[K], and complains. But it does understand that TSelectorMap<TableNames> is a subtype of TSelectorMap<K>, so we can safely "upcast" by annotating a new variable of the wider type, and assigning.

    And now, finally, getSelectorData():

    const getSelectorData = <K extends TableNames>(
      arrayOfObj: TableMap[K][],
      name: K
    ) => {
      const selectorData = getEmptySelectorObject(name);
    
      for (const col in selectorData) {
        selectorData[col] = Array.from(
          new Set(arrayOfObj.map(obj => obj[col]))
        ).sort((a, b) => String(a).localeCompare(String(b)));
      }
       
      return selectorData;
    };
    

    It's been made generic, and it works. There's a little bit of type safety hole remaining; you can still call getSelectorData() where K is the full TableNames union, and the for loop doesn't quite check if you're assigning the wrong property to the wrong column (col is just keyof TSelector<K> more or less, but you would really want a generic P extends keyof TSelector<K>)... but that's the first-order refactoring.

    So, is it worth it to you? I couldn't say, but I'd much rather maintain code I personally understood then code someone in Stack Overflow gave me with links to a bunch of GitHub issues.

    Playground link to code