Utility Types или почему я не люблю enum
Добрый день всем читателям и писателям. Меня опять зовут Юрик и я опять сочиняю про Angular. В этой части разговора будет больше про TS, но расскажу зачем вообще использовать utility types.
Итак, на собеседованиях часто спрашивают про utility types, коронный добивающий вопрос по ним связан с infer. О нем расскажу в конце статьи. Только вот интервьюеры в ответ что-то не хотят рассказывать, а как собственно они применяют эти самые utility types, какие задачи или проблемы решают.
Сначала посмотрим, а что нам говорит по этому поводу документация.
Из документации следует, что в 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, т.е. вывод типа. Мы можем проверить соответствие типа и задать логику. В типах. Логику.??? Выглядит пугающе и запутанно, но оно работает. На тип ToKebab имеет первый дженерик как строку. Далее идет магия. T extends ${infer First}${infer Rest} определяет, что Т соответствует строке где First и Rest - это внутренние дженерики и мы указывает логику if else, указывая что мы будем делать в зависимости от typeof First или typeof Rest. First и Rest мы получили через infer, где First указывает на первый символ строки, Rest - второй. Рекурсивный вызов ToKebab перебирает всю строку модифицируя выходной результат. Итак, как это происходит. Строка 2 и 6. Проверяется на соответствие строки T паттерну ${infer First}${infer Rest} . Строка не должна быть пустой. Если не так - оставляем строку R (она в рекурсии). Если соответствие есть - проверяем первый символ First на uppercase. Если так, определяем тип ToKebab (рекурсивно) и переводим символ в lowercase и ставим перед ним шампур "-", если нет - оставляем без изменений. Тут важно, что R - в зависимости от уровня вложенности имеет значение отличное от пустой строки.
Трудно понять сначала, согласен. Но ребята из https://github.com/type-challenges/type-challenges решают и не такие задачки, попробуйте и вы. ))
Близится конец статьи, но не близится конец Types. Перевод в кебаб мы сделали, что теперь? Теперь есть проблема с kebab-case, который используется в CSS. Дело в том, что поле fontSize в объекте textStyle имеет тип number. В CSS это не допустимо. Выхода 2. Или добавлять к таким ключам ".px" или преобразовывать number в string и добавлять "px". Мы пойдем первым путем. Определим union type как ключи, которые надо преобразовывать и напишем еще два utility type
type ToStyleKebab<T extends string, S extends string> = T extends S ? `${ToKebab<T>}.px` : ToKebab<T>; export type UnionTypeToStyleKebab<T extends string, S extends string> = { [K in T]: ToStyleKebab<T, S>; };
ToStyleKebab - это вспомогательный тип для UnionTypeToStyleKebab Тогда выходной объект будет иметь вид
type TextPixelProperty = | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'borderRadius'; const textProperty: UnionTypeToStyleKebab<TextStylePropertyType, TextPixelProperty> = { fontWeight: 'font-weight', fontStyle: 'font-style', color: 'color', textDecoration: 'text-decoration', fontFamily: 'font-family', textAlign: 'text-align', fontSize: 'font-size.px', letterSpacing: 'letter-spacing.px', lineHeight: 'line-height.px', borderRadius: 'border-radius.px', };
Заметьте, любое изменение типа или объекта сразу выдает ошибку компиляции. т.е. определив типы мы (или кто другой) не сможет изменить объекты, где очень часто можно получить console.error и это критично. Как теперь перемаппить объект JSON в CSS? тут все просто.
const style = Object.keys(textStyle).reduce((acc, key) => { acc[textProperty[key]] = textStyle[key]; return acc; }, {});
При этом, в строке 2 можно использовать данные другого объекта, который например прилетел к нам по слою данных, тогда мы изменим существующий объект другими данными, что устраняет конфликт данных, если например прилетели данные старой версии.
Ну, утомил я уже всех читателей, поэтому буду закругляться. Естественно рабочий код был выложен в репу. Смотрите, изучайте. Совы TS не то, чем кажется.
На этом пока закончу.
