Безопасная для типов группировка в TypeScript

Не стесняйтесь читать это в моем блоге!

GroupBy в Lodash

Если у вас есть значительная база кода на Javascript/Typescript, то, скорее всего, где-то там вы используете lodash.
Хотя Javascript за последние несколько лет стал более «батарейным», в lodash по-прежнему есть много хороших функций для работы с массивами/объектами.
Одной из таких функций является groupBy. Она группирует список по некоторому предикату, в простейшем случае это может быть просто ключ в объектах массива.

import _ from 'lodash';

interface Foo {
  num: number;
  someLiteral: 'a' | 'b' | 'c';
  object: Record<string, any>;
}

const vals: Foo[] = [
  { num: 1, someLiteral: 'a', object: { key: 'value' } },
  { num: 2, someLiteral: 'a', object: { key: 'diffValue' } },
  { num: 1, someLiteral: 'b', object: {} },
];

console.dir(_.groupBy(vals, 'num'));
/*
{
  '1': [ { num: 1, someLiteral: 'a' }, { num: 1, someLiteral: 'b' } ],
  '2': [ { num: 2, someLiteral: 'a' } ]
}
*/
console.dir(_.groupBy(vals, 'someLiteral'));
/*
{
  a:[
      { num: 1, someLiteral: 'a', object: [Object] },
      { num: 2, someLiteral: 'a', object: [Object] }
  ],
  b: [ { num: 1, someLiteral: 'b', object: {} } ]
}
*/
Вход в полноэкранный режим Выход из полноэкранного режима

Все это кажется логичным, вы можете задать, по какому ключу вы хотите группировать, и получите обратно объект, ключи которого являются значениями для найденных во входном массиве объектов.

Теперь, если вы работаете с кодовой базой TypeScript, я надеюсь, вы используете определенно типизированные типы lodash, чтобы добавить некоторые типы к функциям lodash.
В данном случае тип _.groupBy выглядит примерно так (упрощено из реального кода)

declare function groupBy<T>(collection: List<T>, key: string): Dictionary<T[]>;

interface Dictionary<T> {
  [index: string]: T;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь выделяется несколько вещей. Во-первых, тип key — это просто строка, поэтому ничто не мешает мне сделать _.groupBy(vals, "someKeyThatDoesNotExist").
Во-вторых, на уровне типов у нас нет ограничений на то, чтобы я группировал по ключу, значение которого не является допустимым ключом объекта (значение должно быть подмножеством string | number | symbol). Например, в Foo значением ключа object была запись. Вот что произойдет, если вы попытаетесь сгруппироваться по этому ключу.

console.dir(_.groupBy(vals, 'object'));
/*
{
  '[object Object]': [
    { num: 1, someLiteral: 'a', object: [Object] },
    { num: 2, someLiteral: 'a', object: [Object] },
    { num: 1, someLiteral: 'b', object: {} }
  ]
}
*/
Вход в полноэкранный режим Выход из полноэкранного режима

В этом случае объекты были принудительно приведены к строковым значениям, поэтому все элементы vals были сгруппированы под одним и тем же странным ключом [object Object]. Хотя это и не приводит к ошибке, вероятность того, что вы захотите, чтобы это произошло в вашем коде, практически нулевая.

Наконец, возвращаемый тип этой функции — Dictionary, хотя он «правильный», он мог бы быть «более правильным», закодировав, что ключи возвращаемого объекта будут значениями ключа группировки во входном объекте.

Создание собственного groupBy

вставьте сюда шутку Бендера

Чтобы начать создание собственного безопасного типа groupBy, нам сначала нужен код, который действительно выполняет логику группировки. Давайте начнем с этого и некоторых базовых типов.

// Note: PropertyKey is a builtIn type alias of
// type PropertyKey = string | number | symbol
// This lets us use "Record<PropertyKey, any>" to represent any object
// but is slightly nicer to use than the "object" type
function simpleGroupBy<T extends Record<PropertyKey, any>>(arr: T[], key: keyof T): any {
  return arr.reduce((accumulator, val) => {
    const groupedKey = val[key];
    if (!accumulator[groupedKey]) {
      accumulator[groupedKey] = [];
    }
    accumulator[groupedKey].push(val);
    return accumulator;
  }, {} as any);
}

console.dir(simpleGroupBy(vals, 'num'));
/*
{
  '1': [
    { num: 1, someLiteral: 'a', object: [Object] },
    { num: 1, someLiteral: 'b', object: {} }
  ],
  '2': [ { num: 2, someLiteral: 'a', object: [Object] } ]
}
*/
Вход в полноэкранный режим Выход из полноэкранного режима

Логика здесь вроде бы работает, но типы, очевидно, можно было бы немного подправить.

Давайте начнем с добавления еще нескольких дженериков, чтобы мы могли правильно выводить данные.
Первым изменением может стать тип возврата Record<string, T[]>, поскольку ключи будут принудительно преобразованы в строки JavaScript, а значения будут теми же значениями в массиве.
Это, к сожалению, сделает typescript грустным.

function sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]> {
  return arr.reduce((accumulator, val) => {
    const groupedKey = val[key];
    if (!accumulator[groupedKey]) {
      accumulator[groupedKey] = [];
    }
    accumulator[groupedKey].push(val);
    return accumulator;
  }, {} as Record<string, T[]>);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Строки с accumulator[groupedKey] выдадут ошибку Type 'T[keyof T]' cannot be used to index type 'Record<string, T>'. Здесь keyof T может быть любым ключом в T, поэтому, поскольку не каждое значение ключа в T является строкой, typescript не позволит вам рассматривать groupedKey как строку.

Мы можем почти исправить это, добавив дополнительную информацию о входном ключе, привязав его к новому общему параметру, хотя некоторые проблемы все равно останутся.

function betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(
  arr: T[],
  key: Key
): Record<T[Key], T[]> {
  return arr.reduce((accumulator, val) => {
    const groupedKey = val[key];
    if (!accumulator[groupedKey]) {
      accumulator[groupedKey] = [];
    }
    accumulator[groupedKey].push(val);
    return accumulator;
  }, {} as Record<T[Key], T[]>);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы добавили новый дженерик Key extends keyof T, поэтому, когда мы предоставляем функции определенную клавишу, дженерик Key будет сужен до этого значения. Например, если бы мы сделали betterSadAttempt(vals, 'someLiteral'), Key был бы именно 'someLiteral' вместо keyof Foo = 'someLiteral' | 'num' | 'object'.

Однако, typescript по-прежнему печален в строках Record<T[Key], T[]> с Type 'T[Key]' не удовлетворяет ограничению 'string | number | symbol'.
Эта ошибка похожа на предыдущую, в основном T[Key] не может быть ключом для Record, так как это может быть какое-то странное значение.

Для этого нам нужно создать вспомогательный тип, который отфильтрует допустимые ключи только до тех, чьи значения строка | число | символ.
Для этого мы можем использовать сопоставленный тип

type MapValuesToKeysIfAllowed<T> = {
  [K in keyof T]: T[K] extends PropertyKey ? K : never;
};
type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T];
Вход в полноэкранный режим Выйти из полноэкранного режима

Этот помощник типа делает несколько вещей. Во-первых, он перебирает все значения в T ([K в keyof T]) и делает значение ключом, если оно является подмножеством string | number | symbol (T[K] extends PropertyKey ? K), если оно не является подмножеством, то его значением будет тип never. Наконец, мы используем индексный тип доступа, чтобы получить все значения преобразованного объекта как объединение. Этот шаг автоматически отбросит все значения never, поскольку добавить never к объединению — все равно что сказать or false.

Это было многословно, поэтому давайте рассмотрим пример

// from above
interface Foo {
  num: number;
  someLiteral: 'a' | 'b' | 'c';
  object: Record<string, any>;
}

type MappedFoo = MapValuesToKeysIfAllowed<Foo>;
/*
{
  num: "num";
  someLiteral: "someLiteral";
  object: never;
}
*/
// we replace the values of this object with just the key as a string literal or never

type FooKeys = Filter<Foo>;
// => "num" | "someLiteral"
// notice the never does not show up in the union

interface AllObjects {
  object: Record<string, any>;
  diffObject: Record<number, any>;
}

type MappedAllObjects = MapValuesToKeysIfAllowed<AllObjects>;
/*
{ 
  object: never;
  diffObject: never;
}
*/

type AllObjectsKeys = Filter<AllObjects>;
// => never
// the output is only never. Think of this like saying "false or false", the output will just be false
Войти в полноэкранный режим Выйти из полноэкранного режима

С помощью этой вспомогательной функции типа фильтра мы теперь можем правильно ограничить общий Key, заменив Key extends keyof T на Key extends Filter<T>.

Собираем все вместе

type MapValuesToKeysIfAllowed<T> = {
  [K in keyof T]: T[K] extends PropertyKey ? K : never;
};
type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T];

function groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(
  arr: T[],
  key: Key
): Record<T[Key], T[]> {
  return arr.reduce((accumulator, val) => {
    const groupedKey = val[key];
    if (!accumulator[groupedKey]) {
      accumulator[groupedKey] = [];
    }
    accumulator[groupedKey].push(val);
    return accumulator;
  }, {} as Record<T[Key], T[]>);
}

const nums = groupBy(vals, 'num');
// nums = Record<number, Foo[]>

const literals = groupBy(vals, 'someLiteral');
// literals = Record<"a" | "b" | "c", Foo[]>

const sad = groupBy(vals, 'object');
// error: Argument of type '"object"' is not assignable to parameter of type 'Filter<Foo>'
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь это работает отлично, мы можем передавать только те ключи, которые имеют допустимые значения, и мы даже получаем автозаполнение! Однако меня беспокоит одно — сообщение об ошибке в последнем случае.
Хотя оно корректно, сообщение not assignable to parameter of type 'Filter<Foo>' не очень полезно для пользователя. Такое иногда случается с typescript, когда он не показывает базовый тип, а вместо него показывает помощника типа более высокого уровня.

Чтобы заставить сообщение об ошибке показывать допустимые ключи, мы можем использовать модифицированную версию этого «хака». Здесь вместо создания типа Expand в посте, мы можем создать собственный ValuesOf для замены [keyof T] в конце Filter.

type ValuesOf<A> = A extends infer O ? A[keyof A] : never;

type Filter<T> = ValuesOf<MapValuesToKeysIfAllowed<T>>;
// was Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]

const sad = groupBy(vals, 'object');
// error: Argument of type '"object"' is not assignable to parameter of type '"num" | "someLiteral"'
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь у нас есть безопасность типов и хорошие сообщения об ошибках!

Возможные улучшения

Одна вещь, которой не хватает этой функции groupBy, которую дает lodash groupBy — мы не позволяем вам передавать функцию вместо ключа для группировки.
Пример в документации по lodash следующий

_.groupBy([6.1, 4.2, 6.3], Math.floor);
// { '4': [4.2], '6': [6.1, 6.3] }
Войти в полноэкранный режим Выйти из полноэкранного режима

Хотя это и не идеально, но в основном работает

function groupByFunc<
  RetType extends PropertyKey,
  T, // no longer need any requirements on T since the grouper can do w/e it wants
  Func extends (arg: T) => RetType
>(arr: T[], mapper: Func): Record<RetType, T[]> {
  return arr.reduce((accumulator, val) => {
    const groupedKey = mapper(val);
    if (!accumulator[groupedKey]) {
      accumulator[groupedKey] = [];
    }
    accumulator[groupedKey].push(val);
    return accumulator;
  }, {} as Record<RetType, T[]>);
}

const test = groupByFunc([6.1, 4.2, 6.3], Math.floor);
// test = Record<PropertyKey, Foo[]>
Войти в полноэкранный режим Выход из полноэкранного режима

Это работает, позволяя передавать только функции, которые возвращают PropertyKey, но typescript, похоже, не сужает типы. В этом случае test должен быть Record<number, Foo[]>, поскольку TS должен вывести возвращаемый тип группирующей функции. Если вы знаете, как улучшить эту функцию, чтобы возвращаемый тип сужался правильно, не стесняйтесь оставить issue/pr на GitHub моего блога!/JRMurr.github.io)!

Оцените статью
Procodings.ru
Добавить комментарий