Skip to main content

Zero to Hero [ru]

В наши дни, чтобы быть конкурентоспособным в мире фронтенда и Node.js, необходимо знать TypeScript.

Хотя JavaScript, который мы знаем и любим, великолепен, как только код становится немного сложнее, появляются проблемы с его поддержкой. Рассмотрим следующий пример:

const userColorMap = new Map({
'123': ['red', 'blue', 'green'],
'456': ['yellow', 'purple', 'orange'],
'789': ['pink', 'black', 'white'],
});

const userFavouriteColors = userId => {
return userColorMap.get(userId);
};

Выглядит довольно просто, не так ли? Допустим, мы используем эту функцию так:

const favouriteColors = userFavouriteColors('123');
// Добавляем новый цвет к любимым цветам пользователя
favouriteColors.push('brown');

На первый взгляд всё хорошо, но что, если мы сделаем так:

const favouriteColors = userFavouriteColors('000');
// Добавляем новый цвет к любимым цветам пользователя
favouriteColors.push('brown');

Что произойдет? Мы получим знакомую ошибку:

TypeError: Cannot read properties of null (reading 'push')

В чём здесь проблема? Мы передали строку, которой нет в нашем объекте ('000'), и в результате получили null. Попытка вызвать метод push на этом значении вызвала ошибку.

Однако мы, как умные разработчики, могли бы сделать следующее:

const favouriteColors = userFavouriteColors('000') || [];
// Добавляем новый цвет к любимым цветам пользователя
favouriteColors.push('brown');

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

Если бы существовал способ заранее знать, что userFavouriteColors может вернуть null, и обработать этот случай! Именно здесь на помощь приходит TypeScript.

Что такое TypeScript?

TypeScript — это язык программирования, который является надмножеством JavaScript. Это значит, что любой корректный код JavaScript также является корректным кодом TypeScript. Позже мы углубимся в детали того, как он работает, а пока давайте рассмотрим примеры.

Вот простой код на JavaScript:

let x = 10;
x = 'hello';
console.log(x);

Он выполнится без ошибок, и на выходе мы получим hello. С помощью TypeScript мы можем добавить аннотации типов переменным, чтобы гарантировать, что они будут определённого типа. Допустим, в этом примере мы хотим, чтобы x всегда был числом. Вот как это сделать:

let x: number = 10;
x = 'hello';
console.log(x);

Если вы впервые видите TypeScript, то : number может быть вам незнакомым! Это аннотация типа, которая говорит TypeScript, что x всегда должно быть числом. Если мы попытаемся присвоить строку переменной x, мы получим ошибку:

Type 'string' is not assignable to type 'number'.

Здорово! Теперь мы знаем, что x всегда будет числом, и если попытаемся присвоить ему строку, TypeScript предупредит нас.

Базовые типы

В TypeScript есть несколько базовых типов:

  • number: для любых чисел, включая целые и числа с плавающей запятой
  • string: для строковых значений
  • boolean: для логических значений (true или false)
  • symbol: для уникальных идентификаторов, созданных через Symbol() (часто используется в качестве ключей объектов — если вы не использовали символы ранее, не переживайте)
  • null: для значения null
  • undefined: для значения undefined

Если вы работаете с другими языками, отличными от JavaScript, вы могли привыкнуть к разделению целых чисел и чисел с плавающей запятой, а также строк и символов. Поскольку TypeScript является надмножеством JavaScript, а JavaScript не делает таких различий, TypeScript также их не делает. В JavaScript как 10, так и 10.0 являются числами, и нет понятия строкового символа, как в других языках.

Песочница TypeScript

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

Попробуйте выполнить следующий код в песочнице:

let n: number = 10;
let s: string = 'hello';
let b: boolean = true;
let nullValue: null = null;
let undefinedValue: undefined = undefined;
console.log(n.length);
console.log(s.length);
console.log(b.length);

Вы сразу заметите, что первая и третья строки подчеркнуты красным, что указывает на наличие проблемы. Это круто, не так ли? Ошибки будут выглядеть примерно так: Property 'length' does not exist on type 'number'.. Это ошибка компилятора TypeScript, сообщающая нам, что что-то пойдёт не так ещё до выполнения кода. Здорово!

Краткое техническое примечание

Мы только что упомянули "компилятор TypeScript". Это потому, что TypeScript является компилируемым языком, то есть код, который фактически выполняется — это JavaScript, преобразованный из вашего TypeScript-кода. Когда TypeScript компилирует ваш код, он проверяет наличие ошибок и уведомляет вас, если обнаруживает проблемы, как в приведённом выше примере. Аннотации типов, которые вы добавляете в ваш TypeScript-код, на самом деле не меняют поведение вашего кода во время выполнения — они существуют только для помощи TypeScript в определении типов ваших переменных.

Два дополнительных типа: any и unknown

Существуют два дополнительных типа, о которых стоит знать: any и unknown.

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

unknown — это тип, который представляет любое значение. Вы можете рассматривать его как более безопасную версию any. Если вы попытаетесь что-либо сделать со значением unknown, вам нужно будет выполнить некоторые проверки во время выполнения, чтобы убедиться, что это безопасно. См. раздел «Сужение типов» ниже для получения дополнительной информации об этом.

Никогда не говори никогда: never

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

  1. Функции, которые не возвращают значения (например, бросают ошибку или бесконечный цикл):
function throwError(): never {
throw new Error('Error');
}
  1. Недостижимый код:
type Shape = 'circle' | 'square';
function getArea(shape: Shape): number {
if (shape === 'circle') return 1;
if (shape === 'square') return 2;
const _: never = shape; // Ошибка, если в Shape появится новый тип
}
  1. В недопустимых типах:
type ProhibitedNumber = Extract<string | number, never>;
// Результат — `never`, так как ничего не подходит.

Особенности:

  • never — подтип всех типов.
  • Невозможно создать значение типа never

Массивы

Теперь, когда мы знаем основные типы (number, string, boolean), давайте рассмотрим, как использовать массивы в TypeScript:

let numbers: number[] = [1, 2, 3];
let strings: string[] = ['a', 'b', 'c'];
let booleans: boolean[] = [true, false, true];

// Это действие приведёт к ошибке:
booleans.push('hello');

Пока это должно выглядеть знакомо: вы привыкли использовать квадратные скобки для создания массивов в JavaScript, а теперь в TypeScript вы создаёте тип массива, используя имя типа, за которым следуют квадратные скобки ([]).

Кортежи

Кортежи — это особый тип массива, который возможен в TypeScript: это массивы с фиксированной длиной и фиксированным типом для каждого индекса. Вот пример:

type ColorCount = [number, string];

let redCount: ColorCount = [1, 'red'];

// Эта строка вызовет ошибку (несоответствие типов)
let invalidCount: ColorCount = ['hello', 'world'];

// Эта строка также вызовет ошибку (слишком много элементов)
let invalidSizeCount: ColorCount = [1, 'red', 'blue'];

Этот кортеж имеет длину 2 элемента, первый элемент — number, а второй — string. Строка invalidCount вызовет ошибку, потому что первый индекс был указан как string, а не как number.

Функции

Функции в TypeScript имеют несколько переменных частей. Давайте посмотрим на полный пример и разберем каждую часть по отдельности:

const isGreaterThan = (a: number, b: number): boolean => {
return a > b;
};
// эквивалентно:
function isGreaterThan(a: number, b: number): boolean {
return a > b;
}

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

  1. В первом случае параметры в скобках, и у каждого параметра есть свой тип.
  2. После скобок стоит двоеточие (:), за которым следует тип возвращаемого значения функции. В этом случае мы говорим, что isGreaterThan вернет значение типа boolean. Обратите внимание, что для синтаксиса стрелочной функции необходимо поставить стрелку (=>) после типа возвращаемого значения.

Функции с типами как переменные

Ранее мы рассмотрели, как добавить аннотации типов к функции. Но что если ваша функция находится в переменной? Или если она является параметром другой функции? Рассмотрим следующий код на JavaScript:

const createUser = (id, onComplete) => {
const newUser = {
id,
};
onComplete(newUser);
};

В этом примере мы говорим, что createUser принимает два параметра: id и onComplete. id — это string, а onComplete — функция, которая принимает User в качестве параметра и выполняет с ним какие-то действия. Вот как мы бы это типизировали:

const createUser = (id: string, onComplete: (user: User) => void) => {
const newUser = {
id,
};
onComplete(newUser);
};

onComplete получает тип (user: User) => void. Это похоже на то, как мы аннотировали бы функцию как переменную: есть список параметров в скобках, затем стрелка (=>), а затем тип возвращаемого значения. Вы ещё не сталкивались с void — это тип, который означает, что функция ничего не возвращает. Вы также можете типизировать это так:

type OnComplete = (user: User) => void;

const createUser = (id: string, onComplete: OnComplete) => {
const newUser = {
id,
};
onComplete(newUser);
};

Параметры по умолчанию

Для правильного определения функций с параметрами по умолчанию синтаксис выглядит следующим образом:

const createUser = (id: string, name: string = 'John Doe') => {
return {
id,
name,
};
};

Rest-параметры

Rest-параметры в JavaScript выглядят так:

const someFunction = (...args) => {
console.log(args);
};

// или с объектами:
const someFunction = ({ a, ...rest }) => {
console.log(a, rest);
};

Правильно типизировать rest-параметры можно следующим образом:

const someFunction = (...args: number[]) => {
console.log(args);
};

// или с объектами:
const someFunction = ({ a, ...rest }: { a: number; b: number; c: number }) => {
console.log(a, rest); // `rest` имеет тип `{ b: number, c: number }`
};

Перегрузка функций

Перегрузка функций — это продвинутая функция TypeScript, которая позволяет определять несколько функций с одинаковым именем, но с разными параметрами. Вам, вероятно, не придется часто с этим сталкиваться, поэтому я рассмотрю это в будущей статье. Для вашего сведения, это будет выглядеть примерно так:

function add(a: number, b: number): number;
function add(a: string, b: string): string;

Если вы только начинаете знакомиться с TypeScript, не беспокойтесь об этом слишком сильно на данный момент!

Перечисления (enum)

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

Виды перечислений

1. Числовые перечисления (Numeric enums)

Значения автоматически назначаются по порядку, начиная с 0.

enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}

const move = Direction.Up;
console.log(move); // 0
2. Строковые перечисления (String enums)

Здесь каждому элементу задается строковое значение.

enum Colors {
Red = 'RED',
Green = 'GREEN',
Blue = 'BLUE',
}

const favoriteColor = Colors.Green;
console.log(favoriteColor); // "GREEN"
3. Гетерогенные перечисления (Heterogeneous enums)

Смешивают строки и числа (используются редко).

enum Mixed {
Yes = 'YES',
No = 0,
}

Особенности и полезные функции:

1. Двусторонний маппинг (числовые перечисления):
enum Direction {
Up = 1,
Down,
Left,
Right,
}

console.log(Direction[1]); // "Up"
console.log(Direction.Up); // 1
2. Использование в качестве типов:
enum Status {
Active,
Inactive,
Pending,
}

function setStatus(status: Status): void {
console.log(status);
}

setStatus(Status.Active); // 0
3. Константные и вычисляемые значения:
enum Week {
Monday = 1,
Tuesday = Monday + 1,
Wednesday = 10,
}

console.log(Week.Tuesday); // 2

Когда использовать?

  • Когда нужно ограничить выбор фиксированным набором значений.
  • Для улучшения читаемости кода и предотвращения ошибок (например, вместо магических чисел). Пример: Выбор направления, статуса, ролей пользователей и т.д.

Объединение типов: типы объединения и пересечения

Давайте снова рассмотрим наш исходный пример функции userFavouriteColors:

const userColorMap = new Map({
'123': ['red', 'blue', 'green'],
'456': ['yellow', 'purple', 'orange'],
'789': ['pink', 'black', 'white'],
});

const userFavouriteColors = userId => {
return userColorMap.get(userId);
};

Мы знаем, что Map либо вернет массив строк, либо undefined. До сих пор мы видели только одиночные типы, но в TypeScript вы также можете комбинировать типы, используя символ пайп (|):

const userFavouriteColors = (userId: string): string[] | undefined => {
return userColorMap.get(userId);
};

Что это означает? Мы говорим, что userFavouriteColors будет возвращать либо string[] (массив строк), либо undefined. Это называется типом объединения (union). Теперь давайте попробуем сделать push, который мы хотели сделать изначально:

const userColorMap = new Map<string, string[]>({
'123': ['red', 'blue', 'green'],
'456': ['yellow', 'purple', 'orange'],
'789': ['pink', 'black', 'white'],
});
const userFavouriteColors = (userId: string): string[] | undefined => {
return userColorMap.get(userId);
};
const favouriteColors = userFavouriteColors('000');
favouriteColors.push('brown');

Попробуйте вставить это в песочницу TypeScript и посмотрите, какие ошибки вы получите. Вы должны увидеть что-то вроде этого: 'favouriteColors' is possibly 'undefined'.

Отлично, это очень полезно! Хотя мы, вероятно, могли бы распознать это в голове на JavaScript, TypeScript помогает нам, сообщая, что favouriteColors может быть undefined. В сложных (или даже не очень сложных) проектах это сэкономит вам много нервов по мере роста вашего проекта.

Чтобы исправить вышеуказанную ошибку, мы могли бы написать что-то вроде этого:

const favouriteColors = userFavouriteColors('000');
if (favouriteColors) {
favouriteColors.push('brown');
}

Это всего лишь один вариант — мы также могли бы выбросить ошибку, если favouriteColors равен undefined, или реализовать другое поведение.

Пересечения типов работают аналогично, но функционируют как и, а не как или, и используют символ &:

interface User {
id: string;
}

type UserWithRole = User & {
role: string;
};

const someUser: UserWithRole = {
id: '123',
role: 'admin',
};

Типы строкового объединения

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

type Direction = 'up' | 'down' | 'left' | 'right';

const move = (direction: Direction) => {
console.log(direction); // `direction` имеет тип `Direction`, так что мы уверены, что это будет одно из четырех значений
};

Сужение типов

Посмотрите на этот пример еще раз:

const favouriteColors = userFavouriteColors('000');
if (favouriteColors) {
favouriteColors.push('brown');
}

Если вы внимательный читатель, вероятно, вы задавались вопросом: как TypeScript знает, что favouriteColors не является undefined внутри выражения if и позволяет скомпилировать push без ошибок? Это тема сужения типов!

Когда TypeScript "считывает" ваш код, он может делать выводы о типах в зависимости от контекста. В этом примере, хотя тип favouriteColors — это string[] | undefined, внутри выражения if TypeScript знает, что поскольку мы проверили, что favouriteColors не является undefined, это должно быть string[].

Если вы наведете курсор на favouriteColors внутри оператора if, вы увидите, что TypeScript сузил тип до string[], а если наведете курсор на favouriteColors вне оператора if, вы увидите, что TypeScript по-прежнему считает, что тип — string[] | undefined.

Определение типов

До сих пор мы рассмотрели, как аннотировать типы в TypeScript, но, конечно, мы не хотим описывать тип каждой переменной. К счастью, TypeScript часто может определять тип переменной на основе того, как мы её используем.

Ранее мы видели этот пример:

let x: number = 10;
x = 'hello'; // Эта строка вызывает ошибку, потому что мы указали, что `x` - это `number`

Но мы на самом деле можем убрать аннотацию типа для x, и TypeScript определит, что x - это number:

let x = 10;
x = 'hello'; // Эта строка по-прежнему вызовет ошибку

В этом случае, основываясь на первоначальном присвоении 10, TypeScript знает, что x должен быть number. То же самое касается функций:

const add = (a: number, b: number) => {
const result = a + b;
return result;
};

TypeScript не может определить типы параметров, но выше видно, что типа возвращаемого значения нет. Это потому, что TypeScript определяет тип возвращаемого значения на основе переменной result.

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

1. Когда начальное значение недостаточно для определения типа

Рассмотрим следующий пример:

let numOrString = 10;
numOrString = 'hello'; // Эта строка вызовет ошибку

Здесь мы говорим, что хотим, чтобы переменная numOrString могла быть либо number, либо string, но в данном состоянии TypeScript выведет, что numOrString — это number. Вместо этого нам нужно явно аннотировать тип:

let numOrString: number | string = 10;
numOrString = 'hello'; // Эта строка больше не вызовет ошибку

2. Защита при программировании

Иногда вы сами себе худший враг. Вы пишете код сегодня, возвращаетесь к нему через несколько месяцев, немного забываете, что делали, вносите изменения, и теперь всё сломано. Рассмотрим это изменение в функции add, о которой говорилось ранее:

const add = (a: number, b: number) => {
const result = a + b;
return `${result}`;
};

Вы заметили, что я изменил тип возвращаемого значения на string? При выводе типов TypeScript не выдаст здесь ошибку, так как это абсолютно допустимо: у вас есть функция, которая теперь возвращает string. Возможно, я бы сделал это, потому что забыл, что изначально планировал, чтобы эта функция возвращала number. Чтобы избежать этого, мы можем явно аннотировать тип возвращаемого значения:

const add = (a: number, b: number): number => {
const result = a + b;
return result;
};

Теперь, если бы я изменил тип возврата на string, TypeScript выдал бы ошибку на ключевое слово return:

const add = (a: number, b: number): number => {
const result = a + b;
return `${result}`; // Type 'string' is not assignable to type 'number'.
};

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

Утверждения типов

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

Предположим, что в следующем фрагменте кода numFunction — это функция из библиотеки стороннего разработчика, которая возвращает number, но вместо этого разработчик почему-то указал, что она возвращает string. Мы могли бы исправить это в нашем коде, используя утверждение типа:

const result = numFunction();
const resultNumber = result as number; // Мы сообщаем TypeScript, что `result` на самом деле является `number`

Псевдонимы типов

Псевдонимы типов — это как переменные для типов. Они полезны, когда у вас есть сложные типы, которые вы хотите использовать в нескольких местах.

type StringOrNumber = string | number;
const someFunction = (value: StringOrNumber): StringOrNumber => {
return value;
};

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

Типы для объектов: интерфейсы

Интерфейсы — это способ создания типов для объектов в TypeScript (на самом деле, один из способов, но мы к этому еще вернемся!).

interface User {
id: string;
name: string;
}

const someUser: User = {
id: '123',
name: 'John Doe',
};

someUser.name = 3; // Эта строка вызовет ошибку, так как `name` должен быть `string`

const anotherUser: User = {
// У этого `User` отсутствует свойство `name`, поэтому эта строка также вызовет ошибку
id: '123',
};

Эта аннотация имеет несколько частей:

  1. Ключевое слово interface
  2. Название интерфейса, в данном случае User
  3. Свойства, которые имеет интерфейс, в данном случае id и name, каждое из которых является string

Свойства интерфейса — это ключи объекта. Свойства могут быть любого типа, включая другие интерфейсы.

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

Необязательные свойства

Что если мы захотим сделать свойство name необязательным? Мы можем сделать это, добавив вопросительный знак (?) после имени свойства:

interface User {
id: string;
name?: string;
}

// Это эквивалентно:
interface User {
id: string;
name: string | undefined;
}

Свойства только для чтения

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

interface User {
readonly id: string;
}

const someUser: User = {
id: '123',
};

someUser.id = '456'; // Эта строка вызовет ошибку

Расширение интерфейсов

Если вы знакомы с объектно-ориентированным программированием, вам знакома идея "расширения" класса. Интерфейсы работают очень похожим образом.

interface User {
id: string;
}

interface UserWithRole extends User {
role: string;
}

const someUser: UserWithRole = {
id: '123',
role: 'admin',
};

В этом примере UserWithRole имеет все свойства User плюс дополнительное свойство role.

Давайте попробуем немного усложнить ситуацию. Что произойдет, если мы сделаем так:

const someUser: User = {
id: '123',
role: 'admin',
};

Вы получите ошибку, говорящую о том, что у User нет свойства role. А как насчет этого?

const processUser = (user: User) => {
console.log(user);
};

const someUser: UserWithRole = {
id: '123',
role: 'admin',
};

processUser(someUser);

Ошибок нет! processUser ожидает тип User, а someUser - это тип UserWithRole, и поскольку UserWithRole расширяет User, он имеет ожидаемые свойства. Давайте попробуем еще раз:

const processUser = (user: User) => {
console.log(user);
};

processUser({ id: '123' });

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

Любопытные факты о TypeScript: interface против type

Если у вас есть какой-либо вопрос, который вы зададите или который вам зададут о TypeScript, это, вероятно, будет о разнице между interface и type. Это потому, что оба варианта являются допустимыми:

interface User {
id: string;
name: string;
}

type User = {
id: string;
name: string;
};

Так в чем же разница? В текущем виде они функционируют совершенно одинаково. Однако их расширение — это совершенно другая история. Интерфейсы могут расширять другие интерфейсы, но интерфейсы не могут расширять типы. Типы не могут расширять типы в смысле использования extends, но вы можете использовать | для их комбинирования, как мы видели выше с объединениями типов.

Все эти варианты допустимы:

interface User {
id: string;
name: string;
}

interface UserWithRole extends User {
role: string;
}

type UserOrNumber =
| {
id: string;
name: string;
}
| number;

Все эти варианты недопустимы:

// `extends` может использоваться только с интерфейсами или типом `object`
interface UserWithRole extends UserOrNumber {
role: string;
}

interface User {
id: string;
name: string;
} | number; // `|` может использоваться только с типами

Тип object

Вы, вероятно, заметили, что я упомянул тип object в комментариях к последнему примеру. Тип object — это тип, который представляет любое непервичное значение, включая symbol и null. Также вы не можете получить доступ к каким-либо свойствам этого типа. Таким образом, хотя по названию можно ожидать, что это будет основной тип, который вы будете использовать для представления объекта, вы не увидите его очень часто, если вообще увидите.

const someObject: object = {
id: '123',
};

someObject.id; // Эта строка вызовет ошибку

Использование типа свойств интерфейса

Предположим, что у вас уже есть интерфейс, и вы хотите получить тип свойства этого интерфейса. Вы можете сделать это:

interface DataResponse {
data: {
id: string;
name: string;
};
success: boolean;
}

type Data = DataResponse['data']; // { id: string; name: string; }

Это может быть полезно, если у вас есть интерфейс, который содержит тип, но этот тип сам по себе не был указан отдельно. В приведенном выше примере (предположим, что он из библиотеки третьей стороны) хотя они и не предоставили тип для data, вы можете получить тип data, используя приведенный выше синтаксис.

Дискриминируемые объединения

Дискриминируемые объединения — это изящный способ использования TypeScript для представления набора из нескольких возможных объектов, которые имеют общий ключ. Проще всего увидеть это на примере:

type ApiResponse =
| {
type: 'success';
data: {
id: string;
name: string;
};
}
| {
type: 'error';
error: {
code: number;
message: string;
};
};

Тип ApiResponse является дискриминируемым объединением двух типов. У обоих есть свойство type, но значение type различно для каждого из них. Кроме свойства type, тип success имеет свойство data, а тип error имеет свойство error.

Почему это интересно? Это позволяет нам делать что-то вроде этого:

const handleResponse = (response: ApiResponse) => {
if (response.type === 'success') {
console.log(response.data.name);
} else {
console.log(response.error.message);
}
};

В этом примере TypeScript знает, что если response.type равно 'success', то response.data должно существовать. Если response.type равно 'error', то response.error должно существовать. Без разделяемого объединения нам пришлось бы использовать утверждение типа с помощью as, что не так удобно.

Как я уже говорил, разделяемые объединения немного более сложные, но это отличный инструмент, о котором стоит хотя бы знать!

Typeof

До сих пор мы создавали типы, а затем создавали переменные, использующие эти типы. Но что, если у нас есть переменная, и мы хотим узнать её тип? TypeScript поставляется с оператором typeof, который позволяет получить тип переменной:

const someString = 'hello';
type SomeStringType = typeof someString;
// SomeStringType это `string`

Использование typeof с массивами

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

const words = ['hello', 'world'];
type WordsType = (typeof words)[number];
// WordsType это `string`

Этот синтаксис может показаться немного странным с первого взгляда. Помните, как мы раньше говорили, что можно получить доступ к типу свойства объекта, используя interfaceName['propertyName']? Это очень похоже, только вместо объекта вы используете массив.

Имейте в виду, что массивы индексируются числами (т.е. вы можете использовать words[0], words[1] и т.д.). Таким образом, words[number] говорит о "типе элемента в массиве, который может быть индексирован числом" (или, по крайней мере, так я об этом думаю). Поэтому typeof words[number] говорит о "типе значения, которое может быть элементом массива".

Если это объяснение вам не подходит, ничего страшного — просто запомните синтаксис, как он есть!

as const

Есть еще один оператор, который я хочу упомянуть и который может быть полезен: as const. Это утверждение о типе, которое говорит TypeScript рассматривать значение как литеральный тип при его выводе.

const someString = 'hello';
type SomeStringType = typeof someString;
// SomeStringType — это `string`

type SomeStringLiteralType = typeof someString as const;
// SomeStringLiteralType — это `'hello'`

Видите разницу? Без использования as const TypeScript выводит, что someString является string, но с as const TypeScript выводит, что someString — это строка 'hello' конкретно.

Наиболее часто я вижу это в массивах:

const dialogOptions = ['confirm', 'cancel'] as const;
type OptionType = typeof dialogOptions[number];
// OptionType — это `'confirm' | 'cancel'

type OptionLiteralType = typeof dialogOptions[number] as const;
// OptionLiteralType — это `'confirm' | 'cancel'`

Помните, что поскольку TypeScript является компилятором, типы не существуют во время выполнения. Это означает, что вы не можете перебирать список типов в объединении с помощью .forEach или .map! Приведенный выше пример — это способ использовать список значений также как объединенный тип.

Обобщения

Обобщения (generics, дженерики) — это мощная функциональность TypeScript, которая позволяет создавать многоразовые функции и типы, способные работать с несколькими типами. Написание и использование обобщений TypeScript может быть таким сложным, как вы захотите, поэтому я расскажу об этом более подробно в одной из будущих статей.

Тем не менее, есть несколько обобщенных типов, с которыми вам следует быть знакомыми. Давайте рассмотрим синтаксис:

const genericFunction = <T>(value: T): T => {
return value;
};

// или
// function genericFunction<T>(value: T): T {
// return value;
// }

type T1 = genericFunction<string>'hello'; // возвращаемый тип — `string`
type T2 = genericFunction<number>123; // возвращаемый тип — `number`

В этом примере мы говорим, что genericFunction имеет обобщенный тип под названием T. Какой бы тип ни был, genericFunction требует единственный параметр под названием value этого типа, и он также возвращает значение этого типа.

Самые важные встроенные обобщённые типы

Существует несколько встроенных обобщённых типов (Utility Types), которые вы будете использовать довольно часто.

  • Record<K, V>: объект с ключами типа K и значениями типа V (т.е. K — это тип ключа, а V — тип значения)
  • Partial<T>: объект в форме T, но со всеми свойствами, которые являются необязательными
  • Pick<T, K>: объект в форме T, но только со свойствами из K, например, Pick<{ id: string, name: string }, 'id'> — это объект со свойством id, но без свойства name
  • Omit<T, K>: делает обратное Pick, исключая свойства K из T
  • Promise<T>: обещание, которое разрешится в T
  • Awaited<T>: тип значения, которым разрешится обещание (например, если у вас есть Promise<string>, то Awaited<Promise<string>> — это string)
  • Array<T>: массив типа T, эквивалентный T[]
  • Map<K, V>: отображение от K к V (т.е. K — это тип ключа, а V — тип значения)
  • Set<T>: множество типа T
  • Nullable<T>: тип, который может быть T или null
  • ReturnType<T>: возвращаемый тип функции T
  • Parameters<T>: параметры функции T

Record<K, V> вероятно, является самым важным из этих типов. Вы будете использовать его постоянно для представления объектов различных видов:

const someObject: Record<string, number> = {
hello: 1,
world: 2,
};

// Эта строка не вызовет ошибку, так как TypeScript может отследить ключи, явно указанные в объекте `someObject`
someObject.hello = 1;

// Эта строка не вызовет ошибку, но результат будет `number | undefined`, так как TypeScript не знает, какие ключи есть в `someObject`, только что они типа `string`
someObject['hello'] = 2;

// Эта строка не вызовет ошибку, новое значение можно добавить, так как типы ключа и значения корректны
someObject['newkey'] = 3;

// Эта строка не вызовет ошибку, так как числовой ключ будет неявно преобразован в строку `'10'`.
someObject[10] = 4;

// Эта строка вызовет ошибку, так как значение должно быть типа `number`
someObject['hello'] = 'world';

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

В TypeScript ключи объекта (даже если они имеют тип string) могут быть представлены типом string | number | symbol, поскольку JavaScript неявно преобразует числовые ключи в строки при использовании в объектах. Поэтому запись someObject[10] = 4; не вызовет ошибку. Это связано с тем, что TypeScript интерпретирует 10 как ключ, который будет преобразован в строку '10'.

Как предотвратить использование числовых ключей?

  1. Добавьте тип для числовых ключей, который возвращает never:
const someObject: { [key: string]: number; [key: number]: never } = {
hello: 1,
world: 2,
};

someObject[4] = 3; // Error: Type 'number' is not assignable to type 'never'.
  1. Используйте Map вместо объекта, он явно различает числовые и строковые ключи.
const someMap = new Map<string, number>();
someMap.set('hello', 1);
someMap.set('world', 2);
someMap.set(4, 3); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.

В отличие от других способов представления объектов, вы можете добавлять любые ключи к Record<K, V>, если они типа K.

Также есть эквивалентный способ записи Record<K, V>:

const someObject: { [key: string]: number } = {
hello: 1,
world: 2,
};

// эквивалентно
const someObject: Record<string, number> = {
hello: 1,
world: 2,
};

Классы

Классы в TypeScript выглядят несколько иначе, чем в JavaScript.

class User {
constructor(name) {
this.name = name;
}

sayHello() {
console.log(`Hello, ${this.name}!`);
}
}

В TypeScript этот класс будет выглядеть так:

class User {
public name: string;

constructor(name: string) {
this.name = name;
}

sayHello(): void {
console.log(`Hello, ${this.name}!`);
}
}

Здесь есть несколько новых элементов:

  • Переменная name объявляется с типом string до ее использования в конструкторе
  • Переменная name имеет модификатор public, что означает, что к ней можно получить доступ отовсюду (другие варианты — private и protected, что означает, что доступ возможен только внутри класса или его подклассов соответственно)
  • Параметры конструктора типизированы
  • Для функции класса sayHello указан тип возвращаемого значения void

Конспект

Похвалите себя, вы добились значительного прогресса в TypeScript! Информации было много, поэтому вот конспект для вас:

// -----
// Базовые типы
// -----
const stringValue: string = 'привет';
const numberValue: number = 123;
const booleanValue: boolean = true;
const nullValue: null = null;
const undefinedValue: undefined = undefined;
// const anyValue: any = 'привет'; // Избегайте использования `any`, если это возможно

// -----
// Объединённые типы
// -----
type StringOrNumber = string | number;

// -----
// Функции
// -----
const add = (a: number, b: number): number => {
return a + b;
};
// или
function add(a: number, b: number): number {
return a + b;
}
// Функция, возвращающая void
const log = (message: string): void => {
console.log(message);
};
// Функция, использующая параметр-объект
const processUser = (user: { id: string; name: string }): void => {
console.log(user);
};
// Функция с необязательным параметром
const logOptional = (message?: string): void => {
console.log(message);
};
// Функция с оставшимся параметром
const logRest = (...messages: string[]): void => {
console.log(messages);
};
logRest('сообщение1', 'сообщение2');

// -----
// Утверждения типов
// -----
const result = someThirdPartyFunction();
const typedResult = result as number;

// -----
// Интерфейсы
// -----
interface User {
id: string;
name: string;
}
// Расширение интерфейса
interface AdminUser extends User {
role: string;
}
// Использование типа для объектов
type UserOrNumber = {
id: string;
name: string;
};
// Необязательные свойства
interface UserWithOptionalValues {
id: string;
name?: string;
role?: string;
}
// Пересечение типов
type UserWithRole = User & { role: string };
// Получение типов свойств интерфейса
type Data = DataResponse['data'];
// Дискриминированные объединения
type ApiResponse =
| {
type: 'success';
data: {
id: string;
name: string;
};
}
| {
type: 'error';
error: {
code: number;
message: string;
};
};

// -----
// Использование typeof
// -----
const someString = 'hello';
type SomeStringType = typeof someString; // SomeStringType это `string`
// Использование typeof с массивами
const words = ['hello', 'world'];
type WordsType = typeof words[number]; // WordsType это `string`

// -----
// Использование as const
// -----
const someString = 'hello';
type SomeStringLiteralType = typeof someString as const; // SomeStringLiteralType это `hello`

// -----
// Дженерики
// -----
const genericFunction = <T>(value: T): T => {
return value;
};

// Наиболее важные встроенные обобщенные типы
type RecordType = Record<string, number>;
type PartialType = Partial<User>;
type PickType = Pick<User, 'id'>;
type OmitType = Omit<User, 'id'>;
type PromiseType = Promise<string>;
type AwaitedType = Awaited<Promise<string>>;
type ArrayType = Array<string>;
type MapType = Map<string, number>;
type SetType = Set<string>;
type NullableType = Nullable<string>;
type ReturnType = ReturnType<() => string>;
type ParametersType = Parameters<(a: number, b: number) => number>;

// -----
// Классы
// -----
class User {
public name: string;

constructor(name: string) {
this.name = name;
}

sayHello(): void {
console.log(`Привет, ${this.name}!`);
}
}