Как стать автором
Обновить
285.76
ГК ЛАНИТ
Ведущая многопрофильная группа ИТ-компаний в РФ

TypeScript: стоит ли усложнять типы?

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров2.1K

Что такое TypeScript? Официальная документация отвечает так: “TypeScript — это JavaScript с синтаксисом типов”. Однако некоторые считают TypeScript своеобразным слиянием двух языков: языка для манипулирования значениями JavaScript и языка для манипулирования типами.

Cистема типов TypeScript Тьюринг-полная. Это означает, говоря по-простому, что система может решить любую вычислительную задачу при наличии некоторого представления входных и выходных данных.

Можно ли использовать это знание на практике? Как избежать крайностей от примитивного аннотирования типов до избыточного усложнения?

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

  • увидеть в типах инструмент проектирования, а не только контроль ошибок;

  • сбалансировать строгость и гибкость: когда стоит углубиться в типы, а когда — остановиться;

  • применять продвинутые техники — от брендированных типов до конечных автоматов.

Система типов, будучи Тьюринг-полной, обладает вычислительной мощью, сравнимой с полноценным языком программирования. На практике это позволяет реализовать, например, систему типов TypeScript, используя только систему типов TypeScript. Некоторые умельцы уже сделали это, выглядит это так:

// Error: ["7: Argument of type 'string' is not assignable to parameter of type 'number'."]
type Errors = TypeCheck<`
function square(n: number) {
	return n * n
}
square("2");
`>;

Кроме HypeScript существуют и другие интересные проекты на типах, например, SQL-движок и интерпретатор lisp-подобного языка. Система типов предоставляет мощные возможности для реализации таких проектов, но практической ценности здесь немного. Это явление известно как гимнастика типов (type gymnastics). Делают это чаще ради развлечения, а не из необходимости.

Можно подумать, что такая универсальность, когда система достаточно умна, чтобы вычислять любую программу, может быть труднодостижима, но оказывается наоборот: труднее написать полезную систему, которая не стала бы Тьюринг-полной. Подробнее об этом можно почитать здесь.

Ключевой момент — это уровень выразительности системы. На этом этапе становятся очевидны её ограничения. В качестве примера можно изучить код из zustand.

type Cast<T, U> = T extends U ? T : U
type Write<T, U> = Omit<T, keyof U> & U
type TakeTwo<T> = T extends { length: 0 }
  ? [undefined, undefined]
  : T extends { length: 1 }
    ? [...a0: Cast<T, unknown[]>, a1: undefined]
    : T extends { length: 0 | 1 }
      ? [...a0: Cast<T, unknown[]>, a1: undefined]
      : T extends { length: 2 }
        ? T
        : T extends { length: 1 | 2 }
          ? T
          : T extends { length: 0 | 1 | 2 }
            ? T
            : T extends [infer A0, infer A1, ...unknown[]]
              ? [A0, A1]
              : T extends [infer A0, (infer A1)?, ...unknown[]]
                ? [A0, A1?]
                : T extends [(infer A0)?, (infer A1)?, ...unknown[]]
                  ? [A0?, A1?]
                  : never

Так что же такое TypeScript? Два языка в одном? Или просто JavaScript с типами? Существует ли здесь баланс? Следует ли всегда стремиться к максимально простым типам, или иногда их усложнение оправдано? Попытаемся ответить на эти вопросы.

Типы, тесты, проверки в рантайме

В этой части рассмотрим, как статическая типизация повышает надёжность и корректность кода. Сравним преимущества и недостатки с другими методами: тестированием, проверками в рантайме.

Разберём такую задачу: написать функцию, которая принимает массив чисел и числовое значение, а затем возвращает индекс заданного значения. Важно сигнализировать, если значение отсутствует в массиве.

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

function findInArray(array, value) {
	// Реализация
}

Такой пример не даёт никакой полезной информации кроме того факта, что функция принимает два параметра, и не обеспечивает её корректное использование. Напишем тесты, чтобы исправить это:

const array = [1, 2, 3, 4, 5];

describe('findInArray', () => {
    test('возвращает индекс найденного значения', () => {
        const result = findInArray(array, 3);

        expect(result).toBe(2);
    });
  
    test('возвращает null, если значение отсутствует', () => {
        const result = findInArray(array, 0);

        expect(result).toBe(null);
    });
});

Теперь у нас больше информации о назначении функции и есть уверенность в том, что функция корректно отрабатывает в заданных кейсах. Однако тесты не могут охватить все возможные сценарии. Например, мы не можем утверждать, что некорректные данные на входе всегда будут правильно обрабатываться. Попробуем это исправить, добавив проверку аргументов в runtime:

function findInArray(array, value) {
	if (!Array.isArray(array))
		throw new TypeError('Первый аргумент должен быть массивом');

	if (typeof value !== 'number')
		throw new TypeError('Второй аргумент должен быть числом');

	// ...
}

Такая проверка отловит все случаи, когда функция получает некорректные аргументы. Но у такой проверки есть существенный недостаток – она зависит от удачи разработчика. Существует разница между введением ошибки и выявлением ошибки. Если разработчик не проверит все сценарии использования функции, он не заметит, что в некоторых случаях функция может получить некорректные данные. Проблема всплывёт лишь тогда, когда с ней столкнется пользователь или в лучшем случае тестировщик.

И всё ещё нет гарантий, что вызывающий код корректно обрабатывает результат функции. Например, он может не ожидать возврата null.  Теперь добавим типы:

function findInArray(array: number[], value: number): number | null {
	// ...
}

Типизация помогает отлавливать целый класс ошибок. Правильно написанные типы служат документацией, проверяются автоматически во время компиляции и всегда актуальны, так как отражают то, с чем работает код. Этот пример простой, но основные принципы, которые он иллюстрирует, применимы и к более сложным ситуациям.

Типы, тесты и проверки в рантайме не взаимозаменяемы. Тестами можно протестировать что угодно, но они проверяют конкретные кейсы, охватывают лишь одну проблему за раз. Полностью покрыть код тестами для всех вариантов использования невозможно. Типы помогают выявлять целые классы ошибок на этапе компиляции, но эти классы ограничены. Однако, используя более продвинутые возможности TypeScript, можно расширить класс отлавливаемых ошибок.

Понятно, что TypeScript способен выявлять простые ошибки, например, передали string вместо number. Однако большинство проблем возникает именно в бизнес-логике, и здесь также можно воспользоваться преимуществами системы типов. Далее рассмотрим несколько техник, которые помогут отследить такие ошибки.

Бизнес и типы

Группируем связанные поля.

Рассмотрим тип, содержащий информацию о контакте:

type Contact = {
	firstName: string;
	lastName: string;
	emailAddress: string;
	isEmailVerified: boolean;
}

Здесь нас интересуют два свойства: emailAddress и isEmailVerified. Свойство isEmailVerified равно true, если пользователь подтвердил свой адрес электронной почты. Действует такое бизнес-правило: если пользователь изменил почту, необходимо обновить флаг верификации.

const contact: Contact = {
	firstName: "John",
	lastName: "Doe",
	emailAddress: "doe@mail.ru",
	isEmailVerified: true
}

// Забыли сбросить isEmailVerified
contact.emailAddress = "johnDoe@mail.ru";

TS playground

В такой конфигурации можно легко упустить обновление флага isEmailVerified, что приведёт к логическим ошибкам в коде. Для решения проблемы можно сгруппировать связанные данные в объект или кортеж:

type Contact = {
	firstName: string;
	lastName: string;

	email: [string, isVerified: boolean]
}

// Ошибка: Тип 'string' не совместим с типом '[string, isVerified: boolean]'.
contact.email = "johnDoe@mail.ru"
// Ок
contact.email = ["johnDoe@mail.ru", false]

TS playground

Совет здесь простой: группируйте данные, которые должны быть связаны, и не группируйте то, что не подлежит объединению. Это позволит поддерживать целостность и логику ваших данных.

Делаем невозможные состояния невозможными

Смоделируем дверь. Дверь может находиться в различных состояниях: заперта или отперта, открыта или закрыта.

type Door = {
	opened: boolean,
	locked: boolean
}

// Дверь открыта, но заперта?
const uselessDoor: Door = {
	opened: true,
	locked: true
}

TS playground

В данной конфигурации возможна ситуация, когда дверь открыта, но одновременно и заперта. Это не имеет смысла. Как можно решить эту проблему? Решение простое: дверь может находиться в конечном числе состояний, поэтому просто перечислим возможные состояния с использованием union.

type LockedDoor = {
	locked: true,
	opened: false
}

type UnlockedDoor = {
	locked: false,
    opened: boolean
}

type Door = LockedDoor | UnlockedDoor

// Error: Types of property 'locked' are incompatible. Type 'true' is not assignable to type 'false'.
const uselessDoor: Door = {
	opened: true,
	locked: true
}

TS playground

Больше никаких бесполезных дверей, TS подсветит нам ошибку. Код становится самодокументируемым: достаточно взглянуть на типы, чтобы понять, как устроена бизнес-логика. Благодаря чёткому определению состояний мы можем избежать логических ошибок и сделать наш код более предсказуемым. Типы сохраняют свою актуальность и проверяются машиной, что делает наш код более надежным.

Делаем state-machines на типах

Определим все возможные состояния двери:

// Дверь может быть заперта или отперта
type Door = LockedDoor | UnlockedDoor

type LockedDoor = {
	opened: false,
	locked: true
}

// Если дверь не заперта, она может быть открытой или закрытой
type UnlockedDoor = OpenedDoor | ClosedDoor

type OpenedDoor = {
	opened: true,
	locked: false
}

type ClosedDoor = {
	opened: false,
	locked: false
}

Как и в предыдущем примере все состояния двери задокументированы. Теперь определим переходы между состояниями.

type UnlockDoor = (door: LockedDoor) => UnlockedDoor

type LockDoor = (door: UnlockedDoor) => LockedDoor

type OpenDoor = (door: UnlockedDoor) => OpenedDoor

type CloseDoor = (door: OpenedDoor) => ClosedDoor

TS playground

Мы получили ещё больше информации о нашей двери. Каждый из этих типов функций отвечает за чёткое определение поведения системы при смене состояний. Например, функция UnlockDoor может быть использована для перехода от запертого состояния двери к разблокированному, а функция OpenDoor — для открытия незапертой двери.

Но есть и свои недостатки. Этот подход усложняет структуру типов, перед его использованием нужно убедиться, что плюсы перевешивают минусы. Учтите следующие моменты.

  • Некритичность состояния для бизнеса. Если изменение состояния не влияет на поведение системы, возможно, стоит рассмотреть более простой подход.

  • Частые изменения бизнес-правил. Если бизнес-логика часто меняется, сложные формулировки и многоуровневые типы могут привести к дополнительным проблемам при обновлении кода.

Использование машин состояний для моделирования бизнес-логики в TypeScript делает документацию ещё подробнее. Такой подход проектирования заставит разработчика думать о каждой возможности, которая может возникнуть. Но здесь особенно важно помнить о балансе выгод и затрат.

Branded types

Рассмотрим типы, представляющие идентификаторы поста и автора: PostId и AuthorId. Несмотря на то, что оба типа являются string, они не должны восприниматься как обычные строки и они не должны восприниматься как взаимозаменяемые. Например, у нас есть функция для получения комментариев к определённому посту от определённого автора:

async function getCommentsForPost(postId: string, authorId: string) {...}

// перепутали id пользователя и поста
getCommentsForPost(user.id, post.id) {...} // TS это пропустит

Если кто-то случайно перепутает порядок аргументов, мы можем получить комментарии не от нужного автора или для другого поста. Эта ошибка может привести к неожиданным багам.

Чтобы избежать таких проблем, был придуман паттерн Branded Types. Создадим такую конструкцию:

declare const __brand: unique symbol;

type Branded<T, UniqueKey> = T & { [__brand]: UniqueKey };

Как это работает.

  1. Мы принимаем тип T и расширяем его уникальным свойством __brand. Поскольку unique symbol равен только самому себе, мы можем отличать “особые” примитивы от обычных.

  2. Мы помещаем brand в вычисляемое свойство, чтобы сделать его невидимым. Это означает, что доступ к authorId.brand невозможен.

  3. Мы присваиваем свойству __brand уникальный ключ, чтобы различать разные брендированные типы. Например, UserID отличается от PostID.

type UserID = Branded<string, "UserID">

type PostID = Branded<string, "PostID">

const userId: UserID = "123" as UserID;

const postId: PostID = "456" as PostID;

async function getCommentsForPost(postId: PostID, authorId: UserID) {...}

getCommentsForPost(userId, postId); // Ошибка: Аргументы типа 'UserID' и 'PostID' в данном контексте несовместимы.

TS playground

Брендированные типы гарантируют, что операции требующие особых примитивных типов, не примут неподходящие значения.

url-parser

Вернемся к теме полноты по Тьюрингу и рассмотрим более сложный пример. Допустим, в команде договорились об определенных правилах написания URL. Наша задача — из строки, представленной в виде: /users/:usersId/comments/:commentsId, получить объект с URL-параметрами в качестве свойств.

Перед тем, как приступить к реализации, необходимо освоить несколько концепций.

Условные типы и infer

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

type ArrayElement<T> = T extends (infer Element)[] ? Element : never

// string | number | boolean
type Test = ArrayElement<(number | string | boolean)[]>

TS playground

Шаблонные литералы в TypeScript позволяют реализовывать строковые шаблоны на уровне типов. С помощью такого шаблона можно, например, вести поиск определенной подстроки. Например, попробуем извлечь имя из литерального типа: "name: John, surname: Doe". Реализуем такой шаблон:

type ExtractName<T> =
    T extends `name: ${infer Name},${string}` ? Name : never;

TS playground

Как работает данный тип?

  1. Строковый шаблон сопоставляется с типом T, полученным на входе. Шаблон сопоставляется по всей длине строки, поэтому в конце мы ставим ${string}, что аналогично использованию .* в регулярных выражениях.

  2. В блоке infer Name извлекается подстрока с именем и сохраняется в Name.

  3. Возвращаем найденное значение.

Типы можно использовать рекурсивно. Например, создадим тип, описывающий массив чисел любой вложенности.

// Каждый элемент массива может содержать либо число, либо другой массив.
type NestedArray = Array<number | NestedArray>;

const nestedArray: NestedArray = [1, [2, [3]]];

TS playground

Теперь объединим все это в единый тип. Создадим строковый шаблон для сопоставления параметра и сохранения остатка URL.

`${string}:${infer Parameter}/${infer Rest}`

Применив его к строке /users/:usersId/comments/:commentsId, в переменной Parameter окажется "usersId", а в Rest"comments/:commentsId". Для конца строки понадобится немного другой шаблон, так как в нем уже не будет слэша /:

`${string}:${infer Parameter}`

И используем рекурсию, чтобы извлечь остальные параметры, которые сохранены в Rest.

Объединим всё:

type UrlParamsToUnion<URL, Acc = never> =
	URL extends
		`${string}:${infer Parameter}/${infer Rest}` ? UrlParamsToUnion<Rest, Acc | Parameter> :
	URL extends
		`${string}:${infer Parameter}` ? Acc | Parameter : Acc

// Полученный union тип затем легко преобразовать в тип объекта с помощью следующего кода:
type ParamsUnionToObj<T extends string> = {
    [K in T]: string;
}

type UrlObj<T> = ParamsUnionToObj<UrlParamsToUnion<T>>;

Затем создадим такую функцию:

const getUserCommentsURL = '/users/:usersId/comments/:commentsId' as const;

const interpolateURLParameters = <T,>(url: T, parameters: UrlObj<T>) => {}

interpolateURLParameters(getUserCommentsURL, { usersId: '123', commentsId: '1234' });

TS playground

Хотя данный код может показаться не самым выразительным, думаю, он весьма полезен. Он гарантирует, что пользователи не забудут передать все необходимые параметры, а также упрощает рефакторинг, если мы решим изменить имена параметров, TypeScript поможет нам в этом. При наличии понятных названий и необходимых пояснений такой подход упростит работу с кодом и принесёт ощутимую пользу.

Вывод

TypeScript идет по интересному пути. Система типов становится языком программирования, и где-то типы уже тестируют. Например, zod, zustand, tanstack query. Понятно, что разработка библиотеки – не то же самое, что продуктовая разработка, но тесты типов живут рядом с тестами функционала – это уже реальность. Некоторые пытаются доработать систему типов, чтобы упростить с ней работу, например, hotscript. Я не утверждаю, что всё это нужно тащить в проект, но, думаю, важно понимать, в каком направлении это движется и как это может быть полезно.

Я считаю, что понимание и осознанное использование возможностей системы типов TypeScript может повысить надёжность и документированность кода. Здесь важно найти баланс. Чем проще типы, тем больше допущений делаем. С другой стороны, чем сложнее и полнее типы, тем больше информации и гарантий мы получаем для кода. Однако есть риск создания сложночитаемого трудноподдерживаемого типа, что приведёт к обратному эффекту. 

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

*Статья написана в рамках ХабраЧелленджа 3.0, который прошел в ЛАНИТ осенью 2024 года. О том, что такое ХабраЧеллендж, читайте здесь.

Теги:
Хабы:
+16
Комментарии0

Публикации

Информация

Сайт
lanit.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
katjevl