Pull to refresh

Очередная статья про систему типов TypeScript

Reading time8 min
Views10K

Немного введения

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

Про себя я рассказывать не буду. Ничем таким не прославился. Просто сижу пишу код 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-тип.

Поэтому, если захочется использовать рекурсию, то она должна быть:

  1. неглубокой;

  2. ограниченной;

Вот тут-то мы начинаем использовать мощность синего друга.

Практика

А давайте затипизируем несколько функций со строками? А то что это такое сейчас есть: передал строку - получил строку. Что делает функция - неизвестно. Продолжаем высасывать проблемы из пальца.

Начнём с простенького - конкатенация. Что нам нужно? 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 метод с выводом типа возвращаемого значения при запросе).

Обычно там в конце статьи люди что-то оставляют, но у меня ничего нет, чтобы оставить вам, простите.

Tags:
Hubs:
Total votes 13: ↑11 and ↓2+11
Comments22

Articles