Utility Types или почему я не люблю enum
Добрый день всем читателям и писателям. Меня опять зовут Юрик и я опять сочиняю про Angular. В этой части разговора будет больше про TS, но расскажу зачем вообще использовать utility types.
Итак, на собеседованиях часто спрашивают про utility types, коронный добивающий вопрос по ним связан с infer. О нем расскажу в конце статьи. Только вот интервьюеры в ответ что-то не хотят рассказывать, а как собственно они применяют эти самые utility types, какие задачи или проблемы решают.
Сначала посмотрим, а что нам говорит по этому поводу документация.
https://www.typescriptlang.org/docs/handbook/utility-types.html
Из документации следует, что в TS существую типы, которые проводят модификации других типов. Рассмотрим простой Pick<Type, Keys>
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, "title" | "completed">;
const todo: TodoPreview = {
title: "Clean room",
completed: false,
};
Кто-то сразу догадался, но уверен большинство новичков не сразу или не догадались. Наш Pick<Type, Keys>
забирает Keys
из типа (интерфейса) Type
, результатом которого будет новый тип. При этом, эти типы будут связаны. Изменение интерфейса Todo необратимо изменит константу todo еще до рантайма. Ошибка в определении типов вызовет ошибку сборки бандла. И если в enum изменение запросто может пройти сборку и ошибка в рантайме вызовет красную консоль, то тут нет. В принципе, нет ничего плохого в использовании enum и дальше, но utility types намного технологичнее что-ли. Тем более, что использовать enum надо тоже с умом. Кто может объяснить разницу использования?
export enum Todo {
//someting
}
export const enum Todo2 {
//something
}
С Pick<Type, Keys>
понятно. В документации представлены еще куча подобных types. Но эти utility types не являются частью языка, как enum
, например. Они написаны на TS. Давайте разберем как это работает на примере нашего Pick<Type, Keys>
В коде это выглядит следующим образом:
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
Вот тут вот уже намного больше людей скажут:
Вот тут вот уже все написано именно на TS. И таких типов мы можем написать самостоятельно сколько угодно ровно под наши задачи. Например, трансформация JSON camelCase в kebab-case CSS. Дочитайте до конца и это там будет. А как работает, спросите вы? Да очень просто, отвечу я. Разберем с самого начала что, откуда и как интерпретируется интерпретатором и как это потом используется в JS.
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
Определяется тип
Pick
с двумя дженериками<T, K>
С типом
T
все понятно - это или тип или интерфейс, в вот сK extends keyof T
посмотрим внимательнее.К
соответствует ключамT
, вот как это будет объяснено, т.е.keyof T
имеет типstring
иK
должен быть строкой и соответствовать ключам типаT
. Если мы вK
поместим строку не соответствующую ключамT
- будет ошибка компиляции. А мы еще до создания самого типа не дошли.Создаем тип. Выражение
[P in K]:
определяет keys объекта, который будет создан по типу. Поэтому определяется еще один дженерикP
и он хранит тип ключа. Получаем что-то вродеtypeof P === 'key name'
, а т.к.P in K
, то оператор in обходит циклом все значенияK
. Итак, мы получаем тип с ключами, представленными вК
, и которые являются ключамиT
Каждому ключу назначаем его value. Выражение
T[P];
прямо нам и говорит, берем value из объектаT
по ключуP
Тут я намеренно писал "из объекта" потому что типы не являются объектами, но по ним строится объект. Именно объект проверяется на соответствие типу и именно объект потом уходит в JS. Как мы знаем вся писанина по типам TS останется за бортом конечного бандла JS.
Штош, как он работает мы определились. А как насчет того как его применять? Поехали дальше.
Конкретно Pick<Type, Keys>
можно применять для определения strict partial model, т.е. нам нужна конкретная часть определенной модели данных например. Если не строгая модель, то мы имеем тип Patrial<T>
Где еще? Давайте сделаем обещанный camelCase to kebab-case. Для чего он нужен? Когда-то делал очень продвинутый WYSIWYG-редактор и маппер перевода данных из JSON в SafeStyle был типизирован и обходил ровно то, что относилось к типу, а не то, что прилетело.
Что сначала? Сначала типизируем модель JSON.
type TextStylePropertyType =
| 'fontWeight'
| 'color'
| 'fontFamily'
| 'fontSize'
| 'textDecoration'
| 'letterSpacing'
| 'lineHeight'
| 'textAlign'
| 'fontStyle'
| 'borderRadius';
export type UnionTypeToValue<T extends string> = {
[K in T]: any;
};
const textStyle: UnionTypeToValue<TextStylePropertyType> = {
fontWeight: 'none',
color: '#ffffff',
fontFamily: 'Muli',
fontSize: 16,
textDecoration: 'none',
letterSpacing: 'normal',
lineHeight: 'normal',
textAlign: 'left',
fontStyle: 'none',
borderRadius: 0,
};
Тут все понятно? Имеем union type TextStylePropertyType, на основании которого создаем объект стилизации текста с дефолтными значениями. Теперь делаем тип для kebab.
type ToKebab<T extends string, R extends string = ''>
= T extends `${infer First}${infer Rest}`
? Uppercase<First> extends First
? ToKebab<Rest, `${R}-${Lowercase<First>}`>
: ToKebab<Rest, `${R}${First}`>
: R;
и вот тут вылазит наш infer на сцену и начинает мутить воду. Что нам говорит документация. Документация нам говорит, что infer
- это type inference, т.е. вывод типа. Мы можем проверить соответствие типа и задать логику. В типах. Логику.