Немного введения
Вы уже все знаете, какая выразительная система типов в нашем синем друге, ведь об этом было написано немало статей. Думаю, втирать рассказывать об этом уже нет смысла, поэтому сразу перейдём к делу.
Про себя я рассказывать не буду. Ничем таким не прославился. Просто сижу пишу код 24/7, потому что нравится.
Вывод типов
Да-да, начнём именно с таких основ. Позже будем углубляться.
Давайте рассмотрим следующий код:
let str = '123';
let anotherStr: '123' = '123';
Для первой переменной мы не указывали тип, поэтому язык нам выведет тип string
для неё. Для другой же мы явно указали тип, который, в принципе предполагает, что мы в эту переменную никакую другую строку поместить не сможем. К слову, эта возможность нам сегодня и пригодится.
Думаю, шаблонные строки должны быть знакомы всем - это довольно мощный инструмент в языке программирования, и он не обошёл стороной даже нашего сине-белого товарища:
let numbers: '1234567890';
let letters: 'abcdefghijklmnopqrstuvwxyz';
let possibleSymbols: `${typeof numbers}${typeof letters}`;
Тут довольно простая операция - мы конкатенируем типы numbers
и letters
, получая при этом новый тип, который будет состоять из цифр и букв английского алфавита в нижнем регистре. Впрочем, это всё можно сократить до следующего кода (если это будет необходимо):
let possibleSymbols: '1234567890abcdefghijklmnopqrstuvwxyz';
В принципе по большей части мы сегодня будем работать в основном со строками (и ещё парочкой преимуществ).
Типы-утилиты
По мимо мощной системы типов микромягкие добавили утилиты для более простой работы с типами. Например, у нас есть интерфейс пользователя:
interface IUser {
username: string;
password: string;
address: string;
ip: string;
}
// Я долго думал, это у меня код неверный или подсветка не так работает на хабре
// Сообщите, если всё же код неверный, а то вдруг
И, например, нам нужно описать функцию авторизации для пользователя. Давайте согласимся, что для авторизации нам не нужен адрес проживания и IP-адрес пользователя. Поэтому воспользуемся типом Pick
следующим образом:
declare function authenticate(
credential: Pick<IUser, 'username' | 'password'>
): boolean;
В теле функции нам IDE будет подсказывать, что мы можем использовать только поля username
и password
.
А вам тоже надоело проверять постоянно переменную/свойство на существование? Пожалуйста, даже для этой проблемы есть решение - приведение типов!
declare function maybeString(): string | undefined;
maybeString().split(''); // Тут будет ошибка "Object is possibly 'undefined'"
Мы можем понять его - он старается заботиться о нас и уберечь нас от опасности. Но мы безбашенные, поэтому нам всё равно на какие-то там опасности. Перепишем, но функцию оставим такой же!
let onlyString: string = <string>maybeString();
Если вы приверженец Чехова, то для есть другой синтаксис:
let onlyString: string = maybeString()!;
Предупреждение: не используйте это никогда. Все действия выполнены непрофессионалом на свой страх и риск в чисто ознакомительных целях. Типы хороши, но если логика хромает, то никакие типы не спасут (имею ввиду типы в TypeScript)
От простого к сложному - infer
Для меня данная функция языка была очень долго загадкой, но со временем я стал её понимать лучше, благодаря строкам (похоже на всеми ненавистную рекламу).
Давайте возьмём пример высосанный из пальца:
type Str<T> = T extends `${infer R}` ? R : never;
Что собственно этот тип делает? Мы передаём в generic некий тип Т
, а далее проверяем, наследуется ли он от некой строки с infer R
... А что такое infer
? Представьте, что это что-то в духе переменной, только вот значение определяет сам TypeScript, мы можем только указать конкретное место, а TypeScript подставит из типа Т
сам.
Итого получаем следующие вариации:
let notString: Str<123>; // вернёт never
let daEtoStroka: Str<'123'>; // вернёт '123'
Ограничения, безусловно, есть:
infer
может использоваться только с применениемextends
;Время жизни ограничивается блоком для выполнения истинного условия тернарного оператора. То есть, код:
type S<T> = T extends `${infer R}` ? T : R;
будет выдавать ошибку при транспиляции.
Рекурсия
На этом этапе уже можно назвать систему типов отдельным языком программирования - что у нас тут только нет: и типы, и работа со строками, и переменные, и даже рекурсия.
Собственно, как и во всех других языках программирования, тут рекурсия тоже не бесконечная. Так например следующий код выдаст ошибку.
type Recursive<T> = Recursive<T>;
Если быть точнее, то код выдаст две ошибки. Сначала подсветит определение типа и скажет, что у него там есть неразрешимая рекурсия. Потом подсветит значение типа и скажет, что Recursive
- это не generic-тип.
Поэтому, если захочется использовать рекурсию, то она должна быть:
неглубокой;
ограниченной;
Вот тут-то мы начинаем использовать мощность синего друга.
Практика
А давайте затипизируем несколько функций со строками? А то что это такое сейчас есть: передал строку - получил строку. Что делает функция - неизвестно. Продолжаем высасывать проблемы из пальца.
Начнём с простенького - конкатенация. Что нам нужно? Generic-функция и всё
function concat<T extends string, T1 extends string>(
a: T,
b: T1,
): `${T}${T1}` {
return <`${T}${T1}`>a + b;
}
Тут нам пригодится приведение типов, потому что при обычной конкатенации нам бы вывелся тип string
, а нам нужен немного ограниченный string
.
Зато в таком случае подсказки будут решать проблемы уже за вас
Но что это? Кажется, мы ограничены в количестве параметров. Давайте исправим этот момент. Для этого создадим отдельный тип, который будет превращать массив строк в одну строку:
type Join<T, _O extends string = ''> = T extends [
infer D extends string,
...infer R
]
? Join<R, `${_O}${D}`>
: _O;
Что за ещё один параметр _O
? Мы туда будем класть каждый строковый элемент просто потому, что можем. Собственно вот и практическое применение рекурсии. Если на каком-то из этапов мы получим пустой массив, то сразу выдадим значение параметра _O
.
А теперь определим новую функцию, которая может принимать уже сколько угодно строк и выводит верный тип.
Далее в статье будет опускаться логика функций/методов.
declare function concat<T extends string[]>(...strs: T): Join<T>;
В таком случае, если мы попробуем использовать данную функцию, то получим следующую подсказку:
Что же, с конкатенацией тут всё. Перейдём к следующей такой же важной функции, как разбивание строки на массив по разделителю. Добро пожаловать, split
.
Данный метод принимает разделитель и лимит. Опустим последний параметр, потому что TypeScript ещё не научился выполнять операции над числами.
Давайте попробуем описать эту операцию с помощью типов:
type Split<
T,
S extends string,
_O extends string[] = []
> = T extends `${infer R}${S}${infer D}`
? D extends ''
? S extends ''
? [..._O, R]
: [..._O, R, '']
: Split<D, S, [..._O, R]>
: T extends `${infer R}`
? [..._O, R]
: _O;
Что, собственно тут происходит? В нашем случае T
- это строка, которую передаём, а S
- это разделитель. Собственно, _О
- результат выполнения. Мы забираем из строки первую подстроку, за которой идёт разделитель, за которой идёт остальная часть строки.
Здесь очень хорошо видна природа infer
. Последний объявленный infer
в строковом типе будет жадным, то есть, он заберёт всё, в то время как идущие перед ним будут забирать первое попавшееся совпадение. Отсюда делаем вывод, что R
- конкретная подстрока, а не объединение подстрок.
Далее проверяем, а пусто ли после разделителя, если да, то возвращаем массив с добавлением пустой строки (так поступает стандартный split
). В противном случае продолжаем выполнять рекурсию. Если на каком-то этапе у нас останется строка без разделителя, то добавляем её к нашему массиву и возвращаем результат, в противном случае возвращаем просто массив, ничего к нему не добавляя.
Опишем функцию:
declare function split<
T extends string,
S extends string
>(str: T, sep: S): Split<T, S>;
Попробуем воспользоваться и получим такую подсказку:
На вкусненькое
Раз уж мы продвинулись настолько вперёд, то предлагаю вам посмотреть парсер JSON на типах Typescript Парсер здесь (тыкни на меня).
Код парсера, чтобы далеко не ходить
type json = '{ "a": [1, 2] }'
type Trim<T> = T extends ` ${infer R}`
? Trim<R>
: T extends `${infer R} `
? Trim<R>
: T
type StringToArray<T extends string, _O extends any[] = []> = T extends `[${infer R}]`
? Trim<R> extends `${infer E},${infer Other}`
? StringToArray<`[${Trim<Other>}]`, [..._O, JSONParse<E>]>
: Trim<R> extends `${infer E}`
? [..._O, JSONParse<E>]
: never
: never;
type JSONParse<T extends string> = T extends Trim<T>
? T extends `{${string}}`
? StringToObjectV2<T>
: T extends `[${string}]`
? StringToArray<T>
: T extends `${infer R extends number}`
? R
: T extends `${infer R extends boolean}`
? R
: T extends `${infer R extends null}`
? R
: T extends `"${infer R extends string}"`
? R
: never
: never;
type StringToObjectV2<T extends string, _O extends Record<string | number, any> = {}> = T extends `{${infer R}}`
? Trim<R> extends `"${infer Key}":${infer Other}`
? Trim<Other> extends `[${infer Arr}],${infer NewOther}`
? StringToObjectV2<`{${Trim<NewOther>}}`, _O & { [L in Key]: StringToArray<`[${Trim<Arr>}]`> }>
: Trim<Other> extends `[${infer Arr}]`
? _O & { [L in Key]: StringToArray<`[${Trim<Arr>}]`> }
: Trim<Other> extends `${infer Value},${infer LOther}`
? StringToObjectV2<`{${Trim<LOther>}}`, _O & { [L in Key]: JSONParse<Trim<Value>> }>
: Trim<Other> extends `${infer Value}`
? _O & { [L in Key]: JSONParse<Trim<Value>> }
: never
: never
: never;
let l: JSONParse<json>
Также можете глянуть задачу чуть посложнее - инкремент числа. Так как в типах мы не можем использовать математические операторы, то приходится изворачиваться. По такой же аналогии можно реализовать и декремент. Пока что такой инкремент работает корректно только с положительными числами. Суть работы его следующая: мы берём число, превращаем его в строку, разбиваем строку посимвольно, каждый символ превращаем в цифру от 0 до 9. Далее выполняем инкремент последнего элемента массива, если в результате инкремента мы получаем 0, то это означает, что произошло переполнение, а значит нам нужно выполнить инкремент ещё раз, только уже на другом разряде (пользуемся рекурсией). Далее правильно соединяем все части массива и за одно проверяем, не передан ли нам на одном из этапов пустой массив - это помогает тоже обнаружить переполнение - в этом случае мы просто добавляем единицу в начала массива.
Здесь инкремент (тыкни на меня)
Код инкремента
type ToString<T extends string | number | bigint | boolean | null | undefined> = `${T}`;
type Split<
T,
S extends string,
_O extends string[] = []
> = T extends `${infer R}${S}${infer D}`
? D extends ''
? S extends ''
? [..._O, R]
: [..._O, R, '']
: Split<D, S, [..._O, R]>
: T extends `${infer R}`
? [..._O, R]
: _O;
type JoinToNumber<T extends number[], _O extends string = ''> = T extends [infer Digit extends number, ...infer Other extends number[]]
? JoinToNumber<Other, `${_O}${Digit}`>
: T extends [infer Digit extends number]
? `${_O}${Digit}` extends `${infer N extends number}`
? N
: never
: `${_O}` extends `${infer N extends number}`
? N
: never
type PossibleDigitChars = {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6,
"7": 7,
"8": 8,
"9": 9,
}
type DigitWithoutZero = [1, 2, 3, 4, 5, 6, 7, 8, 9]
type IncrementDigit = [...DigitWithoutZero, 0];
type DecrementDigit = [0, ...DigitWithoutZero];
type MapCharToDigit<T extends keyof PossibleDigitChars> = PossibleDigitChars[T];
type MapStringArrayToNumber<
T extends string[],
_O extends number[] = []
> = T extends [infer Digit extends keyof PossibleDigitChars, ...infer Others extends string[]]
? MapStringArrayToNumber<Others, [..._O, MapCharToDigit<Digit>]>
: T extends [infer Digit extends keyof PossibleDigitChars]
? [..._O, MapCharToDigit<Digit>]
: _O
type SplitNumber<T extends number> = MapStringArrayToNumber<Split<ToString<T>, ''>>;
type IncrementArray<
T extends number[],
_O extends number[] = [],
_OverflowFlag extends boolean = false
> = T extends [...infer Others extends number[], infer LastDigit extends number]
? IncrementDigit[LastDigit] extends 0
? IncrementArray<Others, [IncrementDigit[LastDigit], ..._O], true>
: _OverflowFlag extends true
? IncrementDigit[LastDigit] extends 0
? IncrementArray<Others, [IncrementDigit[LastDigit], ..._O], true>
: T extends [...infer Others extends number[], infer _ extends number]
? [...Others, IncrementDigit[LastDigit], ..._O]
: never
: T extends [...infer Others extends number[], infer _ extends number]
? [...Others, IncrementDigit[LastDigit], ..._O]
: never
: T extends []
? [1, ..._O]
: [..._O]
type Increment<T extends number> = IncrementArray<SplitNumber<T>>;
type Eq<T, E extends T> = T extends E ? true : false;
type test = [
Eq<Increment<1>, [2]>,
Eq<Increment<666>, [6, 6, 7]>,
Eq<Increment<999>, [1, 0, 0, 0]>,
Eq<Increment<2999>, [3, 0, 0, 0]>,
]
Вывод
Система типов TS очень выразительная. Позволяет затипизировать почти всё, что угодно.
Не надейтесь на систему типов, потому что-то с плохой логикой типы не помогут справиться.
Данный подход опасен, но при осторожном использовании может быть очень мощным средством (например, есть возможность затипизировать роутер так, чтобы на каждый / в строке была подсказка, а собственно потом можно и разрешить API метод с выводом типа возвращаемого значения при запросе).
Обычно там в конце статьи люди что-то оставляют, но у меня ничего нет, чтобы оставить вам, простите.