Система типов в TypeScript
Hardcore ProgrammerЯзык программирования TypeScript обладает поистине уникальной системой типов и это делает её очень интересной для разбора. Во-первых, это один из немногих представителей структурной типизации. Во-вторых, в основе проверки типов здесь лежит теория множеств, что дает системе типов математическую основу, ну и это просто воспринимается, когда любой тип можно рассматривать как множество входящих в него значений. И наконец в-третьих, TypeScript является надмножеством другого языка - JavaScript, языка с совершенно другой системой типов.
Сегодня подавляющее большинство вакансий связанных с разработкой под web-frontend, Node.js, Electron.js и ReactNative имеют в своих требованиях уверенное знание TypeScript. Если вы разрабатываете в одном из этих направлений, то вам будет крайне полезно узнать этот инструмент глубже. Но даже если ваше направление никак не связано ни с одним из этих направлений, все равно стоит иметь представление о данном инструменте.
Прежде чем погружаться в особенности системы типов, давайте разберемся почему TypeScript получился такой, какой он есть. Создатели данного языка поставили себе весьма амбициозную задачу - привнести статическую и по возможности явную типизацию в JavaScript, язык с динамической неявной типизацией. При этом должна сохраняться обратная совместимость с последним, до той степени, что любой валидный JavaScript код должен так же быть валидным TypeScript кодом. Это позволяет мигрировать с JavaScript на TypeScript постепенно внедряя в проект статические типы. Кроме того разработчики языка подумали и о большём количестве существующих библиотек, написанных на JavaScript, предложив возможность добавлять им статическую типизацию без изменения кода этих библиотек, подключая типы отдельно. Всё это сильно повлияло на то, каким получился TypeScript, разработчикам языка пришлось пойти на ряд компромисов для достижения поставленных целей. Ещё одним важным нюансом является тот факт, что у TypeScript нет спецификации, но при этом он полностю следует спецификации ECMA-262 (ECMAScript), которая является стандартом для языка JavaScript. Отсутствие спецификации сильно усложняет разработку альтернативных компиляторов (таких как swc) и IDE не основанных на официальном language server (например WebStorm). Разработчикам данных инструментов приходится досконально изучать исходники компилятора TypeScript, что весьма не простая задача.
Начнём погружаться в систему типов. Каждый тип в TypeScript представлен как множество всех входящих в него значений. Все типы включены в одну большую иерархию типов, при этом подтипы являются так же подмножествами входящих в тип значений. Наличие такой иерархии делает доступным полиморфизм подтипов, ведь тайпчекер просто проверяет, что тип выражения является подмножеством типа переменной, в которую это выражение присваивается (при этом учитывается тот факт, что любое множество является подмножеством самого себя). В вершине иерархии стоит тип unknown являющийся множеством всех возможных значений, что делает его предком любого другого типа. В низу иерархии находится тип never - пустое множество и потомок для любого другого типа. На первый взгляд неочевидно, зачем нужен тип без значений, применимость этого типа мы рассмотрим чуть ниже, когда будем разбираться с операциями над типами. Есть еще особый тип any, введенный для совместимости с JavaScript, он одновременно является и надмножеством и подмножеством любого другого типа, что по сути отключает любые проверки типа для него (на деле при проверке типов для any вообще не делается никаких проверок). Тип any может быть применим при миграции с JavaScript, но хорошим тоном считается минимизировать его применение (хотя иногда он бывает необходим в некоторых операциях над типами). Еще одним особым типом является void, служайщий типом результата функции, которая ничего не возвращает (не имеет оператора return). В иерархии он располагается в отдельной ветке, его единственный предок - это unknown, а единственный потомок - never. Не смотря на это колбэк требующий возвращаемого типа void может вернуть что угодно, так как результат никак не будет использоваться. Так же в TypeScript есть все типы из JavaScript, при этом следует он спеке ECMAScript, а не сложившемуся поведению оператора typeof, то есть имеется тип null и отсутствует тип function (согласно спеке функции - это объекты). Тип object является предком для большинства других типов (так как у них есть объектные обертки), исключения тут типы undefined, null и void, находящиеся в отдельнах ветвях иерархии, а так же тип unknown, являющийся для object предком. Помимо простых типов имеются составные типы (объекты с конкретными полями и методами, массивы, функции), литеральные типы (типы для конкретных значений) и enum'ы (с числовыми и/или строковыми вариантами).

Unions и intersections
Выше уже сказано, что типы в TypeScript являются множествами значений, а над множествами можно производить некоторые операции. TypeScript позволяет делать 2 из них над типами - объединения (unions) и пересечения (intersections). Union создает надмножество двух типов, включающее в себя все значения из обоих типов. Получившийся тип будет предком для исходных типов. Операция объединения записывается знаком вертикальной черты - |. Тип number | string будет включать в себя все числа и все строки. Это так же можно читать как "число или строка". Intersection двух типов даст тип содержащий только значения входящие в оба исходных типа, то есть по сути подтип для исходных. Операция пересечения записывается знаком амперсанда - &. Если сделать тип number & string, то в таком пересечении не будет общих значений и мы получим пустое множество, то есть тип never. Все гораздо интересенее в случае с составными типами, возьмем пересечение двух объектных типов с разными полями: {a: number} & {b: number} и получим объектный тип содержащий все поля исходных: {a: number; b: number}. А если взять пересечение функциональных типов, то получим перегрузку.
Если применить сюда знания из теории множеств, то можно вывести несколько полезных лайфхаков (слева от стрелочки выражение на типах, а справа - тип получающийся в итоге):
T & unknown -> T (для любого T)
T | never -> T (для любого T)
(1 | 2 | 3 | 'a' | 'b' | 'c') & number -> 1 | 2 | 3
(1 | 2 | 3 | 'a' | 'b' | 'c') & string -> 'a' | 'b' | 'c'
true | false -> boolean
({tag: 1; value: number}
| {tag: 2; data: string})
& {tag: 1} -> {tag: 1; value: number}
({tag: 1; value: number}
| {tag: 2; data: string})
& {tag: 2} -> {tag: 2; data: string}
Дженерики
Помимо полиморфизма подтипов TypeScript так же поддерживает параметрический полиморфизм через дженерики. Дженериками могут быть функции, классы и сами типы. Самое интересное тут, что на дженерики можно накладывать ограничения и ограничения эти опять работают через множества, по принципу что множество дженеричного типа является подмножеством некоего другого типа (не забываем, что любое множество всегда является подмножеством самого себя).
Записывается это с помощью ключевого слова extends. Например:
type MyType<T extends OtherType> = ...
Конструкцию type с дженериком здесь можно рассматривать как чистую функцию на типах, что подводит нас к следующей важной особенности системы типов TypeScript.
Тьюринг полнота системы типов
Обычно, когда люди слышат про тьюринг полноту, они воспринимают это как нечто сугубо положительное, ведь тьюринг полнота означает возможность вычислить любую вычислимую задачу. Для языков общего назначения это действительно хорошо, но система типов нацелена на одну единственную задачу - проверить корректность программы с точки зрения типов. Тьюринг полнота здесь означает возможность подвесить тайпчекер в бесконечном вычислении типа (у меня как-то это получилось в попытке полноценно затипизировать один сложный случай).
TypeScript только с версии компилятора 4.2 научился оптимизировать хвостовую рекурсию в типах и отслеживать бесконечную рекурсию там, где оптимизировать не получается (тип при этом не вычислится, но хотя бы не зависает тайпчекер).
Тьюринг полнота системы типов была вынужденной мерой. JavaScript со своей динамической природой позволяет делать очень многое, а TypeScript должен давать возможность покрыть это многое статическими типами. Система типов позволяет делать и рекурсивные типы, и условные типы (условия опять таки работают на вхождении множества одного типа в множество другого), и даже в какой-то степени циклы (можно итерироваться по отдельным вариантам union'а в условных типах и в генерации полей для объектного типа). Наконец есть паттерн матчинг (ключевое слово infer). Система типов TypeScript по своей сути является почти полноценным лямбда исчислением (нет разве что частичного применения и типов высшего порядка), сгенерировать какой-то код в компйл-тайм так не выйдет, но можно вычислять из одних типов другие по достаточно сложным алгоритмам. Посмотрите на примеры по ссылке.
Думаю вы оценили мощь данной системы типов. Но в мире не бывает идеальных вещей, и данная система типов не является исключением. Выбранный подход к проверке и вычислению типов порой приподносит непрятные сюрпризы. В последнем разделе хочу рассказать о самом (по моей точке зрения) неприятном из них.
Проблемы с функциональными типами
Под функциональным типом здесь подразумевается тип вида:
type Func<Args extends any[], Ret> = (...args: Args) => Ret;
Но прежде чем получится понять, в чем с ним проблема, стоит разобраться с такой вещью как "вариа́нтность". Это характиристика сохранения/изменения отношений предок/потомок в составных типах, когда иерархия составных типов выстраивается на основе иерархии их состовляющих. Различают 4 вида вариантности:
Ковариантность - составной тип сохраняет те же отношения, что и его состовляющие.
Контрвариантность - составной тип имеет обратные отношения от его составляющих.
Инвариантность - у составного типа нет зависимости от его составляющих.
Бивариантность - составной тип имеет отношения в обе стороны - крайне редкий и крайне плохой случай.
Чтобы было понятно, давайте посмотрим на примерах:
Допустим у нас есть 2 типа - A и B, такие что A является подмножеством B.
То есть соблюдается условие A extends B.
Так же у нас есть составной тип T<X>, который будет:
Ковариантным, если соблюдается T<A> extends T<B>.
Контрвариантным, если соблюдается T<B> extends T<A>.
Инвариантным, если не соблюдается ни T<A> extends T<B> ни T<B> extends T<A>.
Бивариантным, если соблюдается и T<A> extends T<B> и T<B> extends T<A>.
Теперь вернёмся к нашему функциональному типу. И объектные типы и массивы являются в TypeScript ковариантными, а вот функциональные типы ковариантны по возвращаемому значению и контрвариантны по аргументам. Зачем так сделали? Допустим у нас есть функция, которая принимает колбэк и передает ему в качестве аргумента тип number, и мы хотим передать в качестве этого колбэка другую функцию, принимающую аргумент типа number | string, является подмножеством
numbernumber | string, а значит такой вызов будет корректный, из чего следует, чтодолжно быть подмножеством
(arg: number | string) => void , что и достигается контрвариантностью.
(arg: number) => void
Первые проблемы начинаются, если мы хотим получать колбэк с произвольными аргументами через дженерик:function f<F extends (...args: unknown[]) => void>(cb: F) { /* ... */ }
Из-за контрвариантности аргументов под F подойдет только функция без аргументов. Попробуем это исправить:function f<F extends (...args: never[]) => void>(cb: F) { /* ... */ }
Читается так себе, но работает... до определенной степени, есть кейсы где и это сломается, по этому единственным надежным способом является указание параметров как any[].
Но на этом проблемы функций не заканчиваются, есть случаи, когда функции сваливаются в бивариантность:
type T0 = {
f0: number;
}
type T1 = {
f0: number;
f1: string;
}
function f0(p: T1): void {}
function f1(p: T0): void {}
type FT0 = typeof f0;
type FT1 = typeof f1;
let v0: FT0 = f1;
let v1: FT1 = f0;
Починить это можно включением опции компилятора strictFunctionTypes, тогда последнее присваивание все же даст ошибку.
Подводя итоги. TypeScript достаточно интересный язык, с мощной системой типов, хоть в ней и есть изъяны, на сегодня это лучшее решение для того что бы наложить типы поверх JavaScript. Если вы работаете с JavaScript, то изучение TypeScript будет для вас необходимостью в современных реалиях. А если даже вам JavaScript в работе не нужен, то изучение TypeScript будет весьма полезным для общего кругозора.