GregRos
GregRos

Reputation: 9113

How to write a function that separately groups an array with objects of different types?

This question concerns the latest version of TypeScript, and so may be updated as newer versions come out.

Let's say I have the following set of types:

type A = {type: "A"; a_data : string};
type B = {type : "B"; b_data : string};
type C = {type : "C'; c_data : string};

And I define the following disjunction type:

type AnyABC = A | B | C;

My goal is to have a function, of the approximate form: (You can remove/add some parameters.)

function groupByKey(array : AnyABC[], key : (abc : AnyABC) => string)

That will accept an array of a mix of A, B, C objects and return an object or an ES6 Map with separate bins for A[], B[], and C[]. So you could go:

let a = result.A[0]

And a would be strongly typed as A.

The actual code for doing this is just simple Javascript and less important. More important is how to encode the types correctly.

Even if there are no A-type values in AnyABC[], I still want the A bin to be an empty array []. This means that the Javascript has to get some kind of object that contains a list of keys, so it can't all be eraseable type information.

I have my own attempt at the solution, which I'm going to add as an answer, but I'm looking for other, possibly better solutions too.

Upvotes: 0

Views: 59

Answers (2)

jcalz
jcalz

Reputation: 328292

You seem to have thought this out, so I don't know if my answer is an improvement. The best I can do is this:

function groupByKeys<T>(
  arr: T[keyof T][],
  selector: (item: T[keyof T]) => keyof T,
  possibleKeys: (keyof T)[],
): ArrayProps<T>;
function groupByKeys<T>(
  arr: T[keyof T][],
  selector: (item: T[keyof T]) => keyof T
): Partial<ArrayProps<T>>;
function groupByKeys<T>(
  arr: T[keyof T][],
  selector: (item: T[keyof T]) => keyof T,
  possibleKeys?: (keyof T)[],
): Partial<ArrayProps<T>> {
  const ret = {} as {[K in keyof T]: T[K][]};
  if (possibleKeys) {
    possibleKeys.forEach(k => ret[k] = []);
  }
  arr.forEach(i => {
    const k = selector(i);
    if (!(k in ret)) {
      ret[k] = [];
    }
    ret[k].push(i);
  })
  return ret;
}

So my possibleKeys parameter differs from yours in that it's an array, not an object. Presumably Object.keys(possibleKeys) on yours results in mine.

type A = { type: 'A', a_data: string };
type B = { type: 'B', b_data: string };
type C = { type: 'C', c_data: string };
type AnyABC = A | B | C;
declare const arr: AnyABC[];

Here's how you call it:

type ABCHolder = { A: A, B: B, C: C };  
groupByKeys<ABCHolder>(arr, i => i.type);

or, if you absolutely care about the output having some empty arrays instead of possibly missing some keys:

groupByKeys<ABCHolder>(arr, i => i.type, ['A','B','C']);

So, this works about the same as yours, with some differences. Upsides:

  • No meaningless inputs; instead you specify the type parameter and it gets erased. Or you pass in the type parameter and a list of keys (which is a bit redundant), but you're not passing in any nulls.

  • Since you specify that type parameter it won't let you put in 'D' in possibleKeys.

  • You are also prevented from doing x => "hello".

Downsides:

  • It would be nice to not need the type parameter or indeed refer to anything like the type {A:A, B:B, C:C}, but such type inference is apparently a little much for the compiler. (A possible refactor is to pass in the actual output object as a parameter, and it will copy results into it. If you're willing to do that I might have a different implementation for you.)

  • Even though x => "hello" is excluded, it still allows x => 'A', which is bad because x might be of type B or C. Ideally the selector function is of the generic type <K extends keyof T>(item: T[K])=>K, which expresses the exact constraint you want... but the compiler absolutely cannot witness that x => x.type matches that, and you are forced to assert it. And since you can assert the same thing of x => 'A', you are given no actual type safefy improvement. So forget that.

So, that's what I have. Hope it's helpful; good luck!

Upvotes: 1

GregRos
GregRos

Reputation: 9113

First, we define:

type KeySetFor<T> = {
    [key : string] : T;
}

This is a type that will encode the key names and what the resulting element type should be.

Then we define:

type KeyResultSetFor<T> = {
    [key in keyof T] : T[key][]
}

This is the result type and what our function returns.

Now comes the tricky part, the function definition:

function groupByKeys<T, TKeySet extends KeySetFor<T>>(array : T[], possibleKeys : TKeySet, selector : (k : T) => string) : KeyResultSetFor<TKeySet>

And you call the function like this:

let arr = [] as AnyABC[];
let x = Arr.groupByKeys(arr, {
    A : null as A,
    B : null as B,
    C : null as C
}, x => x.type);

This works, with the AutoComplete function of my IDE correctly displaying that x has the members {A : A[], B : B[], C : C[]} and the TypeScript compiled will correctly complain if I try to access x.D for example.

However, there are some issues:

  1. The weird null values. You could actually replace them with any other value as long as it's cast to the type A/B/C. They're completely ignored. It's just very weird to see this in an API.
  2. You can also put D : null as C in the keys object and it will work.
  3. You can also put x => "hello" in the selector function and it will work, even though type is of the type "A" | "B" | "C".

Upvotes: 0

Related Questions