Изучение системы типов
Языковая служба TypeScript
Языковая служба TypeScript, также известная как tsserver, предоставляет различные возможности, включая сообщения об ошибках, диагностику, компиляцию при сохранении, переименование, переход к определению, списки автодополнения, подсказки по сигнатурам и многое другое. Она преимущественно используется интегрированными средами разработки (IDE) для поддержки IntelliSense. Служба беспрепятственно интегрируется с Visual Studio Code и используется такими инструментами, как Conquer of Completion (Coc).
Разработчики могут использовать специальный API и создавать собственные плагины языковой службы, чтобы улучшить процесс редактирования TypeScript. Это может быть особенно полезно для реализации специальных возможностей линтинга или включения автодополнения для пользовательского языка шаблонов.
Примером реального специализированного плагина является “typescript-styled-plugin”, который сообщает о синтаксических ошибках и поддерживает IntelliSense для свойств CSS в стилизованных компонентах.
Дополнительную информацию и руководства по быстрому началу работы можно найти в официальной вики TypeScript на GitHub: https://github.com/microsoft/TypeScript/wiki/
Структурная типизация
TypeScript основан на структурной системе типов. Это означает, что совместимость и эквивалентность типов определяются фактической структурой или определением типа, а не его именем или местом объявления, как в номинативных системах типов, таких как C# или C.
Структурная система типов TypeScript разработана с учётом того, как динамическая утиная типизация JavaScript работает во время выполнения.
Следующий пример является допустимым кодом TypeScript. Как можно заметить, “X” и “Y” имеют один и тот же член “a”, хотя им присвоены разные имена при объявлении. Типы определяются их структурами, а поскольку в данном случае структуры одинаковы, типы совместимы и допустимы.
type X = { a: string;};type Y = { a: string;};const x: X = { a: 'a' };const y: Y = x; // ДопустимоОсновные правила сравнения в TypeScript
Процесс сравнения в TypeScript является рекурсивным и выполняется для типов, вложенных на любом уровне.
Тип “X” совместим с “Y”, если “Y” имеет как минимум те же члены, что и “X”.
type X = { a: string;};const y = { a: 'A', b: 'B' }; // Допустимо, поскольку содержит как минимум те же члены, что и Xconst r: X = y;Параметры функций сравниваются по типам, а не по именам:
type X = (a: number) => void;type Y = (a: number) => void;let x: X = (j: number) => undefined;let y: Y = (k: number) => undefined;y = x; // Допустимоx = y; // ДопустимоВозвращаемые типы функций должны совпадать:
type X = (a: number) => undefined;type Y = (a: number) => number;let x: X = (a: number) => undefined;let y: Y = (a: number) => 1;y = x; // Недопустимоx = y; // НедопустимоВозвращаемый тип исходной функции должен быть подтипом возвращаемого типа целевой функции:
let x = () => ({ a: 'A' });let y = () => ({ a: 'A', b: 'B' });x = y; // Допустимоy = x; // Недопустимо: отсутствует член bОтбрасывать параметры функции разрешено, поскольку это распространённая практика в JavaScript, например при использовании “Array.prototype.map()”:
[1, 2, 3].map((element, _index, _array) => element + 'x');Таким образом, следующие объявления типов полностью допустимы:
type X = (a: number) => undefined;type Y = (a: number, b: number) => undefined;let x: X = (a: number) => undefined;let y: Y = (a: number) => undefined; // Отсутствует параметр by = x; // ДопустимоЛюбые дополнительные необязательные параметры исходного типа допустимы:
type X = (a: number, b?: number, c?: number) => undefined;type Y = (a: number) => undefined;let x: X = a => undefined;let y: Y = a => undefined;y = x; // Допустимоx = y; //ДопустимоЛюбые необязательные параметры целевого типа, которым не соответствуют параметры исходного типа, допустимы и не приводят к ошибке:
type X = (a: number) => undefined;type Y = (a: number, b?: number) => undefined;let x: X = a => undefined;let y: Y = a => undefined;y = x; // Допустимоx = y; // ДопустимоОстаточный параметр рассматривается как бесконечная последовательность необязательных параметров:
type X = (a: number, ...rest: number[]) => undefined;let x: X = a => undefined; //допустимоФункции с перегрузками допустимы, если сигнатура перегрузки совместима с сигнатурой реализации:
function x(a: string): void;function x(a: string, b: number): void;function x(a: string, b?: number): void { console.log(a, b);}x('a'); // Допустимоx('a', 1); // Допустимо
function y(a: string): void; // Недопустимо: несовместимо с сигнатурой реализацииfunction y(a: string, b: number): void;function y(a: string, b: number): void { console.log(a, b);}y('a');y('a', 1);Сравнение параметров функций считается успешным, если исходные и целевые параметры могут быть присвоены супертипам или подтипам (бивариантность).
// Надтипclass X { a: string; constructor(value: string) { this.a = value; }}// Подтипclass Y extends X {}// Подтипclass Z extends X {}
type GetA = (x: X) => string;const getA: GetA = x => x.a;
// Бивариантность допускает надтипыconsole.log(getA(new X('x'))); // Допустимоconsole.log(getA(new Y('Y'))); // Допустимоconsole.log(getA(new Z('z'))); // ДопустимоПеречисления совместимы с числами и наоборот, однако сравнение значений из перечислений разных типов недопустимо.
enum X { A, B,}enum Y { A, B, C,}const xa: number = X.A; // Допустимоconst ya: Y = 0; // ДопустимоX.A === Y.A; // НедопустимоПри проверке совместимости экземпляров классов учитываются их закрытые и защищённые члены:
class X { public a: string; constructor(value: string) { this.a = value; }}
class Y { private a: string; constructor(value: string) { this.a = value; }}
let x: X = new Y('y'); // НедопустимоПри сравнении не учитываются различия в иерархии наследования, например:
class X { public a: string; constructor(value: string) { this.a = value; }}class Y extends X { public a: string; constructor(value: string) { super(value); this.a = value; }}class Z { public a: string; constructor(value: string) { this.a = value; }}let x: X = new X('x');let y: Y = new Y('y');let z: Z = new Z('z');x === y; // Допустимоx === z; // Допустимо, даже если z принадлежит другой иерархии наследованияОбобщённые типы сравниваются по их структурам на основе результирующего типа после применения параметра типа. Сравнивается только конечный результат как необобщённый тип.
interface X<T> { a: T;}let x: X<number> = { a: 1 };let y: X<string> = { a: 'a' };x === y; // Недопустимо, поскольку аргумент типа используется в итоговой структуреinterface X<T> {}const x: X<number> = 1;const y: X<string> = 'a';x === y; // Допустимо, поскольку аргумент типа не используется в итоговой структуреЕсли аргументы обобщённых типов не указаны, все неуказанные аргументы рассматриваются как типы “any”:
type X = <T>(x: T) => T;type Y = <K>(y: K) => K;let x: X = x => x;let y: Y = y => y;x = y; // ДопустимоПомните:
let a: number = 1;let b: number = 2;a = b; // Допустимо: любой тип присваиваем самому себе
let c: any;c = 1; // Допустимо: все типы присваиваемы типу any
let d: unknown;d = 1; // Допустимо: все типы присваиваемы типу unknown
let e: unknown;let e1: unknown = e; // Допустимо: unknown присваиваем только самому себе и anylet e2: any = e; // Допустимоlet e3: number = e; // Недопустимо
let f: never;f = 1; // Недопустимо: ничто не присваиваемо типу never
let g: void;let g1: any;g = 1; // Недопустимо: void нельзя присвоить ничему и ему нельзя присвоить ничего, кроме anyg = g1; // ДопустимоОбратите внимание, что при включённом параметре “strictNullChecks” типы “null” и “undefined” обрабатываются аналогично “void”; в противном случае — аналогично “never”.
Типы как множества
В TypeScript тип представляет собой множество возможных значений. Это множество также называется областью определения типа. Каждое значение типа можно рассматривать как элемент множества. Тип задаёт ограничения, которым должен удовлетворять каждый элемент множества, чтобы считаться его членом. Основная задача TypeScript — проверять, является ли одно множество подмножеством другого.
TypeScript поддерживает различные виды множеств:
| Термин теории множеств | TypeScript | Примечания |
|---|---|---|
| Пустое множество | never | ”never” не содержит ничего, кроме самого себя |
| Одноэлементное множество | undefined / null / literal type | |
| Конечное множество | boolean / union | |
| Бесконечное множество | string / number / object | |
| Универсальное множество | any / unknown | Каждый элемент принадлежит “any”, и каждое множество является его подмножеством / “unknown” — типобезопасный аналог “any” |
Ниже приведено несколько примеров:
| TypeScript | Термин теории множеств | Пример |
|---|---|---|
| never | ∅ (пустое множество) | const x: never = ‘x’; // Error: Type ‘string’ is not assignable to type ‘never’ |
| Литеральный тип | Одноэлементное множество | type X = ‘X’; |
| type Y = 7; | ||
| Значение, присваиваемое T | Значение ∈ T (принадлежит) | type XY = ‘X’ | ‘Y’; |
| const x: XY = ‘X’; | ||
| T1 присваивается T2 | T1 ⊆ T2 (подмножество) | type XY = ‘X’ | ‘Y’; |
| const x: XY = ‘X’; | ||
| const j: XY = ‘J’; // Type ‘“J”’ is not assignable to type ‘XY’. | ||
| T1 extends T2 | T1 ⊆ T2 (подмножество) | type X = ‘X’ extends string ? true : false; |
| T1 | T2 | T1 ∪ T2 (объединение) | type XY = ‘X’ | ‘Y’; |
| type JK = 1 | 2; | ||
| T1 & T2 | T1 ∩ T2 (пересечение) | type X = { a: string } |
| type Y = { b: string } | ||
| type XY = X & Y | ||
| const x: XY = { a: ‘a’, b: ‘b’ } | ||
| unknown | Универсальное множество | const x: unknown = 1 |
Объединение (T1 | T2) создаёт более широкое множество (оба множества):
type X = { a: string;};type Y = { b: string;};type XY = X | Y;const r: XY = { a: 'a', b: 'x' }; // ДопустимоПересечение (T1 & T2) создаёт более узкое множество (только общие элементы):
type X = { a: string;};type Y = { a: string; b: string;};type XY = X & Y;const r: XY = { a: 'a' }; // Недопустимоconst j: XY = { a: 'a', b: 'b' }; // ДопустимоВ этом контексте ключевое слово extends можно рассматривать как «является подмножеством». Оно задаёт ограничение для типа. Когда extends используется с обобщённым типом, оно ограничивает параметр обобщённого типа более конкретным типом.
Обратите внимание, что здесь extends не имеет отношения к наследованию классов в смысле ООП.
TypeScript работает со структурными типами и не имеет строгой номинальной иерархии. Как показано в примере ниже, два типа могут пересекаться, хотя ни один из них не является подтипом другого, поскольку TypeScript учитывает структуру, или форму, объектов.
interface X { a: string;}interface Y extends X { b: string;}interface Z extends Y { c: string;}const z: Z = { a: 'a', b: 'b', c: 'c' };interface X1 { a: string;}interface Y1 { a: string; b: string;}interface Z1 { a: string; b: string; c: string;}const z1: Z1 = { a: 'a', b: 'b', c: 'c' };
const r: Z1 = z; // ДопустимоЗадание типа: объявления типов и утверждения типов
В TypeScript тип можно задать разными способами:
Объявление типа
В следующем примере мы используем x: X (”: Type”), чтобы объявить тип переменной x.
type X = { a: string;};
// Объявление типаconst x: X = { a: 'a',};Если переменная не соответствует указанному формату, TypeScript сообщит об ошибке. Например:
type X = { a: string;};
const x: X = { a: 'a', b: 'b', // Ошибка: объектный литерал может содержать только известные свойства};Утверждение типа
Утверждение можно добавить с помощью ключевого слова as. Оно сообщает компилятору, что разработчик располагает дополнительной информацией о типе, и подавляет возможные ошибки.
Например:
type X = { a: string;};const x = { a: 'a', b: 'b',} as X;В приведённом выше примере с помощью ключевого слова as утверждается, что объект x имеет тип X. Это сообщает компилятору TypeScript, что объект соответствует указанному типу, даже если у него есть дополнительное свойство b, отсутствующее в определении типа.
Утверждения типов полезны в ситуациях, когда необходимо указать более конкретный тип, особенно при работе с DOM. Например:
const myInput = document.getElementById('my_input') as HTMLInputElement;Здесь утверждение типа as HTMLInputElement сообщает TypeScript, что результат getElementById следует рассматривать как HTMLInputElement. Утверждения типов также можно использовать для переназначения ключей, как показано в приведённом ниже примере с шаблонными литералами:
type J<Type> = { [ Property in keyof Type as `prefix_${string & Property}` ]: () => Type[Property];};type X = { a: string; b: number;};type Y = J<X>;В этом примере тип J<Type> использует сопоставляемый тип с шаблонным литералом для переназначения ключей Type. Он создаёт новые свойства, добавляя к каждому ключу “prefix_”, а их соответствующими значениями становятся функции, возвращающие исходные значения свойств.
Стоит отметить, что при использовании утверждения типа TypeScript не выполняет проверку избыточных свойств. Поэтому, если структура объекта известна заранее, обычно предпочтительнее использовать объявление типа.
Внешние объявления
Внешние объявления — это файлы, описывающие типы для кода JavaScript; их имена имеют формат .d.ts. Обычно они импортируются и используются для аннотирования существующих библиотек JavaScript или добавления типов к существующим файлам JS в проекте.
Типы для многих распространённых библиотек можно найти здесь: https://github.com/DefinitelyTyped/DefinitelyTyped/
и установить следующим образом:
npm install --save-dev @types/library-nameСобственные внешние объявления можно импортировать с помощью ссылки с «тремя косыми чертами»:
/// <reference path="./library-types.d.ts" />Внешние объявления можно использовать даже в файлах JavaScript с помощью // @ts-check.
Ключевое слово declare позволяет задавать определения типов для существующего кода JavaScript без его импорта, выступая в роли заполнителя для типов из другого файла или глобальной области.
Проверка свойств и проверка избыточных свойств
TypeScript основан на структурной системе типов, однако проверка избыточных свойств — это возможность TypeScript, которая позволяет проверить, содержит ли объект в точности те свойства, которые указаны в типе.
Проверка избыточных свойств выполняется, например, при присваивании объектных литералов переменным или при их передаче в качестве аргументов функции.
type X = { a: string;};const y = { a: 'a', b: 'b' };const x: X = y; // Допустимо благодаря структурной типизацииconst w: X = { a: 'a', b: 'b' }; // Недопустимо из-за проверки лишних свойствСлабые типы
Тип считается слабым, если он содержит только набор необязательных свойств:
type X = { a?: string; b?: string;};TypeScript считает ошибкой присваивание слабому типу значения, с которым у него нет общих свойств. Например, следующий код приводит к ошибке:
type Options = { a?: string; b?: string;};
const fn = (options: Options) => undefined;
fn({ c: 'c' }); // НедопустимоХотя это не рекомендуется, при необходимости данную проверку можно обойти с помощью утверждения типа:
type Options = { a?: string; b?: string;};const fn = (options: Options) => undefined;fn({ c: 'c' } as Options); // ДопустимоЛибо добавив unknown в индексную сигнатуру слабого типа:
type Options = { [prop: string]: unknown; a?: string; b?: string;};
const fn = (options: Options) => undefined;fn({ c: 'c' }); // ДопустимоСтрогая проверка объектных литералов (свежесть)
Строгая проверка объектных литералов, иногда называемая «свежестью», — это возможность TypeScript, которая помогает обнаруживать избыточные свойства или свойства с опечатками, которые в противном случае остались бы незамеченными при обычных проверках структурной типизации.
При создании объектного литерала компилятор TypeScript считает его «свежим». Если объектный литерал присваивается переменной или передаётся как параметр, TypeScript выдаёт ошибку, если объектный литерал содержит свойства, отсутствующие в целевом типе.
Однако «свежесть» исчезает, когда объектный литерал расширяется или используется утверждение типа.
Рассмотрим несколько примеров:
type X = { a: string };type Y = { a: string; b: string };
let x: X;x = { a: 'a', b: 'b' }; // Проверка свежести: недопустимое присваиваниеvar y: Y;y = { a: 'a', bx: 'bx' }; // Проверка свежести: недопустимое присваивание
const fn = (x: X) => console.log(x.a);
fn(x);fn(y); // Расширение: ошибок нет, типы структурно совместимы
fn({ a: 'a', bx: 'b' }); // Проверка свежести: недопустимый аргумент
let c: X = { a: 'a' };let d: Y = { a: 'a', b: '' };c = d; // Расширение: проверка свежести не выполняетсяВывод типов
TypeScript может выводить типы, если аннотация не указана при:
- Инициализации переменной.
- Инициализации члена.
- Установке значений параметров по умолчанию.
- Определении возвращаемого типа функции.
Например:
let x = 'x'; // Выведен тип stringКомпилятор TypeScript анализирует значение или выражение и определяет его тип на основе доступной информации.
Более сложные случаи вывода типов
Если при выводе типов используется несколько выражений, TypeScript ищет «наилучшие общие типы». Например:
let x = [1, 'x', 1, null]; // Выведен тип: (string | number | null)[]Если компилятор не может найти наилучшие общие типы, он возвращает тип-объединение. Например:
let x = [new RegExp('x'), new Date()]; // Выведен тип: (RegExp | Date)[]TypeScript использует «контекстную типизацию» на основе расположения переменной для вывода типов. В следующем примере компилятор знает, что e имеет тип MouseEvent, благодаря типу события click, определённому в файле lib.d.ts, который содержит внешние объявления для различных распространённых конструкций JavaScript и DOM:
window.addEventListener('click', function (e) {}); // Для e выведен тип MouseEventРасширение типов
Расширение типов — это процесс, при котором TypeScript назначает тип переменной, инициализированной без аннотации типа. Этот процесс допускает переход от узких типов к более широким, но не наоборот. В следующем примере:
let x = 'x'; // TypeScript выводит широкий тип stringlet y: 'y' | 'x' = 'y'; // Тип y — объединение литеральных типовy = x; // Недопустимо: тип 'string' нельзя присвоить типу '"x" | "y"'.На основе единственного значения, указанного при инициализации (x), TypeScript назначает переменной x тип string; это пример расширения.
TypeScript позволяет управлять процессом расширения, например с помощью “const”.
Const
Использование ключевого слова const при объявлении переменной приводит к выводу более узкого типа в TypeScript.
Например:
const x = 'x'; // TypeScript выводит для x более узкий тип 'x'let y: 'y' | 'x' = 'y';y = x; // Допустимо: для x выведен тип 'x'При использовании const для объявления переменной x её тип сужается до конкретного литерального значения ‘x’. Поскольку тип x сужен, его можно присвоить переменной y без ошибок.
Тип можно вывести, поскольку переменным const нельзя повторно присваивать значения, поэтому их тип можно сузить до конкретного литерального типа — в данном случае до литерального типа ‘x’.
Модификатор const для параметров типов
Начиная с TypeScript 5.0, для параметра обобщённого типа можно указать атрибут const. Это позволяет вывести наиболее точный возможный тип. Рассмотрим пример без использования const:
function identity<T>(value: T) { // Здесь нет const return value;}const values = identity({ a: 'a', b: 'b' }); // Выведен тип: { a: string; b: string; }Как видно, для свойств a и b выводится тип string.
Теперь рассмотрим различие с версией, использующей const:
function identity<const T>(value: T) { // Использование модификатора const для параметров типа return value;}const values = identity({ a: 'a', b: 'b' }); // Выведен тип: { a: "a"; b: "b"; }Теперь видно, что для свойств a и b выводятся строковые литералы, а не просто типы string.
Утверждение const
Эта возможность позволяет объявить переменную с более точным литеральным типом на основе её начального значения, сообщая компилятору, что это значение следует рассматривать как неизменяемый литерал. Ниже приведено несколько примеров:
Для отдельного свойства:
const v = { x: 3 as const,};v.x = 3;Для всего объекта:
const v = { x: 1, y: 2,} as const;Это может быть особенно полезно при определении типа кортежа:
const x = [1, 2, 3]; // number[]const y = [1, 2, 3] as const; // Кортеж readonly [1, 2, 3]Явная аннотация типа
Можно явно указать тип. В следующем примере свойство x имеет тип number:
const v = { x: 1, // Выведен тип: number (расширение)};v.x = 3; // ДопустимоАннотацию типа можно сделать более конкретной, используя объединение литеральных типов:
const v: { x: 1 | 2 | 3 } = { x: 1, // Теперь x имеет объединение литеральных типов: 1 | 2 | 3};v.x = 3; // Допустимоv.x = 100; // НедопустимоСужение типов
Сужение типов — это процесс в TypeScript, при котором общий тип сужается до более конкретного. Это происходит, когда TypeScript анализирует код и определяет, что некоторые условия или операции позволяют уточнить информацию о типе.
Сужение типов может происходить разными способами, в том числе следующими:
Условия
С помощью условных конструкций, таких как if или switch, TypeScript может сузить тип на основе результата условия. Например:
let x: number | undefined = 10;
if (x !== undefined) { x += 100; // Тип number, суженный условием}Выбрасывание исключения или возврат
Выбрасывание исключения или досрочный возврат из ветви можно использовать, чтобы помочь TypeScript сузить тип. Например:
let x: number | undefined = 10;
if (x === undefined) { throw 'error';}x += 100;К другим способам сужения типов в TypeScript относятся:
- Оператор
instanceof: используется для проверки того, является ли объект экземпляром определённого класса. - Оператор
in: используется для проверки наличия свойства в объекте. - Оператор
typeof: используется для проверки типа значения во время выполнения. - Встроенные функции, такие как
Array.isArray(): используются для проверки того, является ли значение массивом.
Дискриминируемое объединение
«Дискриминируемое объединение» — это шаблон в TypeScript, в котором к объектам добавляется явная «метка», позволяющая различать типы внутри объединения. Этот шаблон также называют «помеченным объединением». В следующем примере «метка» представлена свойством «type»:
type A = { type: 'type_a'; value: number };type B = { type: 'type_b'; value: string };
const x = (input: A | B): string | number => { switch (input.type) { case 'type_a': return input.value + 100; // тип A case 'type_b': return input.value + 'extra'; // тип B }};Пользовательские защитники типа
В случаях, когда TypeScript не может определить тип, можно написать вспомогательную функцию, известную как «пользовательский защитник типа». В следующем примере мы воспользуемся предикатом типа, чтобы сузить тип после применения фильтрации:
const data = ['a', null, 'c', 'd', null, 'f'];
const r1 = data.filter(x => x != null); // Тип (string | null)[]: TypeScript не смог правильно вывести тип
const isValid = (item: string | null): item is string => item !== null; // Пользовательская защита типа
const r2 = data.filter(isValid); // Теперь тип корректен: string[]; предикат защиты типа позволил сузить типСужение с помощью switch (true)
В TypeScript 5.3 появилось сужение с помощью switch (true), позволяющее заменить громоздкие цепочки if/else конструкцией switch (true) с логическими условиями. Оно улучшает читаемость и при этом сохраняет сужение типов. Это похоже на сопоставление с образцом, но проще.
function classify(x: unknown) { switch (true) { case typeof x === 'string': return `"${x.toUpperCase()}"`; case typeof x === 'number': return x > 0 ? 'positive' : 'negative'; case Array.isArray(x): return `[${x.length} items]`; default: return 'something else'; }}