1

I'm trying to write a function that takes two objects and the name of a key that both objects contain, assuming the associated values are both arrays, and compares the length of the two arrays.

The general form of the function is this:

const compareByKeyLength = <T extends Record<string, unknown[]>, K extends keyof T>(
  a: T,
  b: T,
  key: K,
) => {
  return a[key].length < b[key].length ? 1 : -1;
};

However, I want to modify this so that T does not necessarily extend Record<string, unknown[]>. That is, it cannot be assumed that the T can be indexed by any arbitrary string to get an array. Some keys on the object will be other types. What I want is to limit the allowed values for K such that only the keys which are associated with array values can be given as input.

This is my best guess as to how to implement this, but as you can see, the compiler still doesn't accept that the values will be arrays:

// Find all keys in an object whose type is an array.
type ArrayKeys<O> = {
  [K in keyof O]: O[K] extends any[] ? K : never
}[keyof O];

// Example to test if `ArrayKeys` works.
const example = {
  foo: [1, 2, 3],
  bar: "bar",
  baz: ["one", "two", "three"],
};

// Evaluates to: "foo" | "baz"
type ExampleArrayKeys = ArrayKeys<typeof example>;

// Somehow `T[ArrayKeys<T>]` doesn't prove that the value accessed will be an array. Not sure why.
const compareByKeyLength = <T,>(
  a: T,
  b: T,
  key: ArrayKeys<T>,
) => {
  return a[key].length < b[key].length ? 1 : -1; // Error: Property 'length' does not exist on type 'T[ArrayKeys<T>]'.
};

TS Playground

My questions:

  1. What is the reason my attempt doesn't do what I want? Is it a limitation of the compiler to understand that T[ArrayKeys<T>] will always be an array? Or is that actually unsound and I'm just not seeing why?
  2. Is what I'm trying to do expressible in the type system, or do I need to use runtime checks to verify that the values are actually arrays?
Jeraki
  • 25
  • 5
  • 2
    See the answers to the linked questions for more information (they all mention https://github.com/microsoft/TypeScript/issues/30728 ). If I translate the solution from there it looks like [this](https://tsplay.dev/Nlv4xW). – jcalz May 05 '22 at 02:36

1 Answers1

2

Maybe this is what you need:

const compareByKeyLength = <
  A extends Record<Key, any[]>,
  B extends Record<Key, any[]>,
  Key extends keyof A & keyof B
>
(
  a: A,
  b: B,
  key: Key,
) => {
  return a[key].length < b[key].length ? 1 : -1;
};

compareByKeyLength({a: [], b: 123}, {a: [], c: 123}, "a")

I introduce the generic type Key which is both a keyof A and keyof B. Then I specify that the inputs a and b are of the generic types A and B which both have the key Key with an array type any[].

Playground

Tobias S.
  • 21,159
  • 4
  • 27
  • 45
  • Thanks! This seems to achieve what I want functionally, but the errors produced are confusing. If you change the second argument in your example from "a" to "b", then the compiler highlights the `b` in the first object with the error `Type 'number' is not assignable to type 'any[]'.` This is not exactly right, because any object should be allowed for the first and second arguments. It should be the third argument that produces an error if the value given does not correspond to an array value in both objects. – Jeraki May 04 '22 at 22:06
  • @Jeraki I guess it's just the way TypeScript choses the order to evaluate constraints. It starts with `Key` and then `A` and `B` because they depend on `Key` – Tobias S. May 04 '22 at 22:10
  • I accepted this answer, but the information provided by @jcalz in a comment on my question is actually more fitting. Just FYI for anyone else that comes across this! – Jeraki May 05 '22 at 17:00