Первая часть.

Вывод типа

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

Обратимся к приведенному ранее примеру:

let x: number = 10;
x = 'hello'; // This line has an error because we said that `x` is a `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 выводит возвращаемый тип на основе переменной result.

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

1. Начального значения недостаточно для вывода типа.

Рассмотрим пример:

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

Здесь необходимо иметь возможность установить переменную numOrString либо в number, либо в string. Поскольку в существующем виде TypeScript сделает вывод, что numOrStringnumber, нужно явно указать тип:

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

2. Для выполнения безопасного программирования (defensive programming), предупреждающего потенциальные ошибки.

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

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

Заметили, что я изменил возвращаемый тип на string? Благодаря выводу типов TypeScript не выдаст ошибку, потому что это на 100% корректно: у вас есть функция, которая теперь возвращает 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 часто может вывести их.

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

Утверждения типа (type assertions) — механизм, позволяющий программисту сообщить TypeScript, что значение относится к определенному типу, даже если это не так. Этот механизм не идеален, но в некоторых ситуациях может быть полезен. Недавно я работал со сторонней библиотекой, которая была типизирована сложным образом, что приводило к ошибкам. Благодаря тестированию с помощью утверждений console.log я смог определить тип, который действительно использовался, а затем применить утверждение типа, чтобы обойти ошибку типа.

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

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

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

Псевдонимы типов (type aliases) служат в TypeScript как переменные для типов. Они полезны при необходимости повторно использовать сложные типы в нескольких местах.

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

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

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

Интерфейсы (interfaces) — один из способов создания типов для объектов в 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.
  1. Имя интерфейса (в данном случае User).
  1. Свойства, которыми обладает интерфейс (в данном случае id и name, каждое из которых является string).

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

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

Опциональные свойства

Что, если нужно сделать name опциональным свойством (optional property)? Выполнить эту задачу можно, добавив вопросительный знак (?) после имени свойства:

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

// Это эквивалентно следующему:
interface User {
  id: string;
  name: string | undefined;
}

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

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

interface User {
  readonly id: string;
}

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

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

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

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

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 trivia: interface и type

TypeScript trivia — викторина, которая проверяет знания по TypeScript.

Наиболее часто задаваемый вопрос по TypeScript — вопрос о разнице между interface и type. Связано это с тем, что оба эти понятия имеют право на существование:

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

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

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

Все это допустимо:

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

interface UserWithRole extends User {
  role: string;
}

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

А вот это недопустимо:

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

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

Тип object

Вы, наверное, заметили, что я упомянул тип object в комментариях к последнему примеру. Тип object (объектный тип) — это тип, который представляет любое непримитивное (non-primitive) значение, включая 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 с помощью приведенного выше синтаксиса.

Размеченные объединения

Размеченное объединение (discriminated union) — нетривиальная функция 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 уточняет, что 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;
// }

genericFunction<string>('hello'); // возвращаемый тип - `string`
genericFunction<number>(123); // возвращаемый тип - `number`

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

Наиболее важные встроенные дженерик-типы

Вот встроенные дженерик-типы, которые вы будете часто использовать:

  • 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,
}

someObject.hello; // Эта строка выдаст ошибку, поскольку `hello` не является ключом для `someObject`.
someObject['hello']; // Эта строка не выдаст ошибку, но результатом будет `number | undefined`, поскольку TypeScript "не знает", какие ключи находятся в `someObject`, только то, что они имеют тип `string`.
someObject['newkey'] = 3; // Эта строка не выдаст ошибку, новое значение может быть добавлено, поскольку типы ключа и значения верны
someObject[4] = 3; // Эта строка выдаст ошибку, так как `4` не имеет типа `string`, а ключи должны иметь тип `string`.
someObject['hello'] = 'world'; // Эта строка выдаст ошибку, так как значения должны быть типа `number`.

В отличие от других способов представления объектов, в 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,
}

Классы

Классы (classes) в 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 = 'hello';
const numberValue: number = 123;
const booleanValue: boolean = true;
const nullValue: null = null;
const undefinedValue: undefined = undefined;
// const anyValue: any = 'hello'; // Avoid using `any` if possible

// -----
// Типы объединения
// -----
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('message1', 'message2');

// -----
// Утверждения типов
// -----
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 is `string`
// Использование typeof с массивами
const words = ['hello', 'world'];
type WordsType = typeof words[number]; // WordsType is `string`

// -----
// Использование as const
// -----
const someString = 'hello';
type SomeStringLiteralType = typeof someString as const; // SomeStringLiteralType is `'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(`Hello, ${this.name}!`);
  }
}

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Joshua Saunders: TypeScript: Zero To Hero Plus Cheat Sheet

Предыдущая статьяC++: полное руководство по параметризованным классам
Следующая статьяКак создать Open Source финтех-проект