Объекты

Тип объекта

Ранее мы рассматривали примитивные типы и массивы. Сейчас мы посмотрим на то, как TypeScript работает с объектами

const a = {a: 10};

Какой тип TypeScript присвоит переменной a? У этой переменной будет тип { a: number }. Мы можем явно задать переменной этот тип. Это делается по аналогии с другими типами.

const a: {a: number} = {a: 10}

Если попытаться присвоить переменной с типом {a: number} объект без поля a или с полем a другого типа - TypeScript выдаст ошибку.

const a: {a: number} = {b: 10}; //Error: Type '{ b: number; }' is not assignable to type '{ a: number; }'.
const b: {a: number} = {a: "lalaka"}; //Error: Type 'string' is not assignable to type 'number'.
const c: {a: number} = {a: true}; //Error: Type 'true' is not assignable to type 'number'.

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

const a = {a: 10, b: "lalaka"};
a.a = 300;
a.a = "malaka"; //Error: Type '"malaka"' is not assignable to type 'number'.
a.d = true; //Error: Property 'd' does not exist on type '{ a: number; b: string; }'.

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

Проверка типов объектов

В TypeScript тип объекта x совместим с типом объекта y, если у y есть все поля с совместимыми типами, что и у x.

let x: {a: number, b: string};
const y = {a: 10, b: "lalaka"};
x = y;

При этом тип y может иметь поля, которых нет в типе x.

let x: {a: number, b: string};
const y = {a: 10, b: "lalaka", c: true};
x = y;

Важно заметить, что типы полей двух объектов не обязательно должны быть идентичны. Проверка типов совместимости полей работает по тем же правилам, что и присваивание значения переменной.

let x: {a: number, b: string};
const y: {a: 10, b: "lalaka"} = {a: 10, b: "lalaka"};
x = y;

Такой подход к проверке типов называется структурной системой типов или "утиной типизацией" (duck typing).

Type alias

До этого момента все типы были описаны по месту или использовался автовывод типов. Однако, в TypeScript есть возможность объявить тип отдельно и использовать его в нескольких местах.

type MyType = string

Такая конструкция называется type alias. Таким образом происходит инициализация своего рода "переменной" в системе типизации. Во время компиляции в JavaScript type alias-ы в результирующий файл не попадут.

В type alias можно сохранять любые типы, например, результаты выполнений операций над типами (объединения, пересечения и т.д.), примитивные типы, массивы, объекты, литералы и так далее.

type A = string
type B = number | boolean
type C = A | B
const c: C = 10 // type: string | number | boolean

type Q = {
    a: A;
    b: B;
    c: C;
    tuple: [A, B, "lalaka", string];
    array: number[];
    aliasArray: A[];
}

typeof

Иногда возникает потребность сохранить тип какой-то уже инициализированной переменной и использовать этот тип в другом месте. Для этого в TypeScript есть оператор typeof.

const x = {a: "lalaka", b: 10};
type X = typeof x;

const y: X = {a: "malaka", b: 15} // type: {a: string, b: number}
const z: typeof x = {a: "palaka", b: 30} //type: {a: string, b: number}

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

let x = 17;
type X = typeof x //Работает с типом (ключевое слово type)
const y: /* Работаем с типом (после : перед =) */ typeof x = 15;

const z = typeof x === "string" //Работаем со значением

Интерфейс

Существует еще один способ описать тип объекта - использовать интерфейс.

interface A {
    a: number;
    b: string:
    q: boolean;
}

Для этого необходимо использовать ключевое слово interface, далее указать название типа и без знака = указать тип объекта. Чаще для указания типа объекта используется именно интерфейс, а не type alias.

interface A {
    a: number;
}

const a: A = {a: 10};
type B = A;
type C = {
    a: A;
}
const c: C = {
    a: {
        a: 10
    }
}

Различия между type alias и interface

  • В type alias можно положить не только объект, а любой тип, включая interface или другой type alias.

interface A {
 a: number
}
type B = A; 
type C = B; 
type D = string | 10 | ["lalaka"] | null | undefined;
type E = C | D;
  • При объявлении интерфейсов с одинаковым именем тип объекта расширяется

interface A {
    a: number;
}

interface A {
    b: string;
}

const a: A = { a: 10, b: "lalaka" }

Если же объявить два type alias с одинаковым именем, будет ошибка

type A = { //Error: Duplicate identifier 'A'.
    a: number;
}

type A = { //Error: Duplicate identifier 'A'.
    b: string;
}
  • Интерфейсы могут наследоваться друг от друга. Это можно сделать при помощи ключевого слова extends. При наследовании интерфейс-потомок имеет все поля интерфейса-родителя и те поля, которые объявлены непосредственно у него самого. Type alias наследоваться не могут.

interface A {
   a: number;
}

interface B extends A 
{ 
    b: string; 
}

interface C extends A 
{ 
    c: boolean; 
}

const a: A = { a: 10 }; 
const b: B = { a: 15, b: "lalaka" }; 
const c: C = { a: 20, c: true };

Наследование позволяет выделить у объектов общие признаки и работать с разными значениями как с общим типом. Например.

interface DomElement {
    width: number;
    height: number;
}

interface DivElement extends DomElement {
    textContent: string;
}

interface ButtonElement extends DomElement {
    tabIndex: number;
}

Мы описали интерфейс DomElement, который определяет размеры элемента. У этого интерфейса 2 наследника DivElement и ButtonElement. Оба эти наследника имеют все поля базового типа (а именно width и height) и какие-то собственные поля.

const div: DivElement = {textContent: "lalaka", width: 100, height: 15};
const button: ButtonElement = {tabIndex: 0, width: 500, height: 300};

Не смотря на то, что переменные div и button имеют типы DivElement и ButtonElement соответственно, их всегда можно интерпретировать как DomElement.

let dom: DomElement = div;
dom = button;

const tree: DomElement[] = [
    dom,
    div,
    button,
    div,
    button
];

Однако, как только более конкретный тип (наследник) начинает использоваться как базовый тип, все особенности (поля и методы) конкретного типа теряются (с точки зрения типизация - в runtime все есть).

const q: DomElement = div;
q.textContent // Error: Property 'textContent' does not exist on type 'DomElement'.
tree[1].textContent // Error: Property 'textContent' does not exist on type 'DomElement'.

Это далеко не все отличительные особенности интерфейсов и type alias. Далее будут выявляться все больше отличительных особенностей этих конструкций. Однако, уже сейчас можно сделать несколько выводов:

  • Интерфейсы позволяют типизировать только объекты. Поэтому поддерживают некоторые специфические особенности типизации объектов, такие как наследование и расширение.

  • Type alias предназначены для хранения абсолютно любого типа. Из-за этого они не поддерживают специфические особенности типизации объектов, также как и специфические особенности типизации других типов (строк, булевых значений и т.д.). Но так как тип объекта - это тоже тип, а type alias - хранилище для любого типа, отсюда и появляется это двоякая возможность задания типа объекта.

Readonly и опциональные поля

В TypeScript при описании типа объекта можно указать, что поле является не изменяемыми. Делается это при помощи модификатора readonly.

interface A {
    readonly a: number;
    b: string;
}

Значение этих полей указывается во время инициализации объекта. Присвоить новое значение таким полям нельзя - можно только полностью заменить весь объект.

let a: A;
a = { a: 10, b: "lalaka" }

a.b = "malaka";
a.a = 15 //Error: Cannot assign to 'a' because it is a read-only property.

a = {a: 15, b: "palaka"}

Важно отметить что модификатор readonly работает только во время проверки типов. После компиляции readonly (как и все типы) не попадают в результирующие файлы. Поэтому в runtime эти поля можно изменять как угодно.

Часто возникает потребность описать тип объекта, у которого некоторые поля являются опциональными. Для этого в TypeScript можно воспользоваться знаком ?.

interface A {
    a?: number;
    b: string;
}

const a1: A = { b: "lalaka" };
const a2: A = { a: 10, b: "malaka"}

Поле может быть и опциональным, и readonly одновременно. Такое поле можно не указывать во время инициализации объекта, но нельзя менять далее в коде.

Индексаторы

В TypeScript в типе объекта можно описать индексатор.

interface A {
    [index: number]: boolean;
}

const a: A = {
    [1]: true,
    [-1]: false
}

a[3] = true
a[1] = a[1] === a[-1] === a[100500]

Индексатор говорит о том, что у объекта нет конкретных названий полей. Известен лишь тип полей и тип значения поля. Тип полей может быть или string, или number. Нельзя использовать литеральные типы или их объединения (по крайней мере в interface-ах и в таком виде). Вместо это нужно объявлять тип с обычными полями.

В одном типе могут быть сразу два индексатора с разным типом полей, но тип значений числового индекса, должен быть подтипом строчного индекса. Это ограничение введено из-за особенностей JavaScript. Когда вы используется числовой индекс [10], JavaScript конвертирует значение индекса в строку. Получается, что с точки зрения JavaScript вызов индекса [10] и ["10"] - эквивалентны.

interface A {
    [index: string]: boolean;
    [index: number]: boolean;
}

interface B {
    [index: string]: string;
    [index: number]: boolean; //Error: Numeric index type 'boolean' is not assignable to string index type 'string'.
}

Пересечение объектов

В прошлой части мы говорили про операцию пересечения типов (&). Для простых типов эта операция почти не имеет смысла, но для типов объектов она очень важна. Предположим, у нас есть два тип объекта.

interface A {
    a: number;
}

interface B {
    b: string;
}

Рассмотрим два этих типа с точки зрения множеств. Тип A является множеством всех объектов, у которых есть поле a, значение которого принадлежит множеству number. Тип B - множество объектов с полем b, тип которого string. Из-за того, что TypeScript использует утиную типизацию, то типу A может подходить объект, который имеет поле a: number и еще любое количество других полей с любым типом. Точно также для B.

const q = {a: 10, b: "lalaka"};
const a: A = q;
const b: B = q;

Из этого следует, что существует такие объекты, которые одновременно входят во множество A и во множество B. Множество этих объектов можно обозначить как пересечение множества A и B - A & B. С точки зрения типизации типу A & B можно присвоить любой объект, у которого обязательно должны быть поля a: number и b: string, и любое количество других полей.

type Q = A & B;

const a: Q = { a: 10, b: "lalaka" };
const b: Q = { a: 10 }; /* Error:
Type '{ a: number; }' is not assignable to type 'Q'.
  Property 'b' is missing in type '{ a: number; }' but required in type 'B'.
*/
const c: Q = { b: "malaka" }; /* Error:
Type '{ b: string; }' is not assignable to type 'Q'.
  Property 'a' is missing in type '{ b: string; }' but required in type 'A'.
*/

Взятие тип значения ключа. keyof

В TypeScript из типа объекта по названию ключей можно извлекать типы значений этих ключей.

interface A {
    a: number;
    b: string;
    c: boolean;
}

type Aa = A["a"] //number
type Ab = A["b"] //string
type Ac = A["c"] //boolean

Применяя к типу объекта [] с названием ключа, мы получаем тип значения, который соответствует этому ключу. Если попытаться взять тип значения ключа, которого в объекте нет - TypeScript выдаст ошибку.

type Wrong = A["e"] //Error: Property 'e' does not exist on type 'A'.

Важно, что в [] можно передавать не только один ключ, а объединение названий ключей (например, "a" | "b"). В результате такого действия мы получим тип, который объединяет типы значений переданных ключей.

type Aab = A["a" | "b"] // number | string
type Aac = A["a" | "c"] // number | boolean
type Aabc = A["a" | "b" | "c"] // number | string | boolean

В TypeScript есть специальный оператор keyof. Он возвращает объединение названий ключей типа объекта, к которому его применили.

type Keys = keyof A // "a" | "b" | "c"

Так как keyof возвращает объединение названий, то его результат можно сразу передать в [], чтобы получить объединение всех типов значений ключей объекта

type Q = A[keyof A] //number | string | boolean

Возможно сейчас трудно представить, какие задачи решаются при помощи keyof и взятии типов значений полей. Эти инструменты являются частями более сложных механизмов TypeScript, хотя могут быть использованы и сами по себе. Например, в TypeScript из коробки предоставляется интерфейс HTMLElement. Этот интерфейс является базовым интерфейсом для любого DOM-элемента, который можно получить через document.createElement или каким-то другим способом. Если мы хотим типизировать переменную или поле объекта так, чтобы туда можно было положить значение поля style DOM-элемента нам достаточно сделать такое действие.

const style: HTMLElement["style"] = {...}

Или мы хотим вынести тип поля объекта, не имея типа объекта.

const a = {
    a: 10,
    b: "lalaka"
}

type B = (typeof a)["b"] //string

Задания

  • Пример решения задания #4

Last updated