Совместимость объектных типов (Compatible Object Types)

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

Важно

Пришло время более подробно разобраться как компилятор определяет совместимость объектных типов. Как всегда, вначале, стоит напомнить, что в текущей главе, будет использоваться шаблон (: Target = Source), о котором более подробно шла речь в самом начале.

Но, прежде чем начать погружение в тему совместимости типов (compatible types), будет не лишним заметить, что подобный термин не определен спецификацией TypeScript. Тем не менее, в TypeScript описано два типа совместимости. Помимо привычной совместимости подтипов (assignment subtype), также существует совместимость при присваивании (assignment compatibility). Они отличаются только тем, что правила совместимости при присваивании расширяют правила совместимости подтипов. Сделано это по нескольким причинам.

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

ts
class Animal { public name: string; } class Bird extends Animal { public fly(): void {} } let animal: Animal = new Bird(); // Ok let bird: Bird = new Animal(); // Ошибка, присваивание подтипа let any: any = 0; // Ok let number: number = any; // Ok -> any совместим с number

Кроме того, поведением двухсторонней совместимости наделен и числовой enum.

ts
enum NumberEnum { A = 1 } let v1: number = NumberEnum.A; let v2: NumberEnum.A = 0;

Совместимость объектных типов в TypeScript

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

Простыми словами, два типа будут считаться совместимыми не потому, что они связаны иерархическим деревом (наследование), а по тому, что в типе S (: Target = Source) присутствуют все идентификаторы, присутствующие в типе T. При этом, типы, с которыми они ассоциированы, должны быть совместимы.

ts
class Bird { public name: string; } class Fish { public name: string; } let bird: Bird; let fish: Fish; let v1: Bird = fish; // Ok let v2: Fish = bird; // Ok

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

ts
class Bird { public name: string; public age: number; } class Fish { public name: string; } var bird: Bird = new Fish(); // Error var bird: Bird = new Fish() as Bird; // Ok let fish: Fish = new Bird(); // Ok

Кроме того, два типа, совместимые по признакам идентификаторов, будут совместимы только в том случае, если типы, ассоциированные с идентификаторами, также совместимы.

ts
class Bird { public name: string; public age: number; } class Fish { public name: string; } class BirdProvider { public data: Bird; } class FishProvider { data: Fish; } let birdProvider: BirdProvider = new FishProvider(); // Error let birdProvider: BirdProvider = new FishProvider() as FishProvider; // Error let fishProvider: FishProvider = new BirdProvider(); // Ok

Стоит заметить, что методы, объявленные в объектном типе, сравниваются не по правилам совместимости объектных типов данных. Про правила проверки функциональных типов речь пойдет немного позднее (глава “Типизация - Совместимость функций”). Поэтому комментарии к коду будут опущены.

ts
class Bird { public voice(repeat: number): void {} } class Fish { public voice(repeat: number, volume: number): void {} } let v1: Bird; let v2: Fish; let v3: Bird = v2; // Error let v4: Fish = v1; // Ok

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

ts
class Bird { public voice(repeat: number, volume: number): void; public voice(repeat: number): void {} } class Fish { public voice(repeat: number, volume: number): void {} } let v1: Bird let v2: Fish let v3: Bird = v2; // Ok let v4: Fish = v1; // Ok

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

ts
class Bird { public name: string; public age?: number; public fly?(): void {} } class Fish { public name: string; public arial?: string; public swim?(): void {} } let bird: Bird; let fish: Fish; // class Bird {name: string} === class Fish {name: string} let v1: Bird = fish; // Ok let v2: Fish = bird; // Ok

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

ts
class Bird { public name: string; public age?: number; } class Fish { public name: string; public age: number; } let bird: Bird; let fish: Fish; /** * Bird -> name -> поиск в Fish -> член найден -> Fish['name'] -> Ok * Bird -> age -> age опциональный член -> пропуск */ let v1: Bird = fish; // Ok /** * Fish -> name -> поиск в Bird -> член найден -> Bird['name'] -> Ok * Fish -> age -> поиск в Bird -> член найден -> Bird['age'] не является опциональным -> Ошибка */ let v2: Fish = bird; // Error let v3: Fish = bird as Fish; // Ok

Существует еще одна неочевидность, связанная с необязательными членами. Если в целевом типе все члены объявлены как необязательные, он будет совместим с любым типом, который частично описывает его, при этом тип-источник может описывать любые другие члены. Помимо этого он будет совместим с типом, у которого описание отсутствует вовсе. Но он не будет совместим с типом, у которого описаны только отсутствующие в целевом типе члены. Такое поведение в TypeScript называется Weak Type Detection (обнаружение слабого типа). Типы, описание которых состоит только из необязательных членов, считаются слабыми типами.

ts
class IAnimal { name?: string; age?: number; } class Animal {} class Bird { name: string; } class Fish { age: number; } class Insect { name: string; isAlive: boolean; } class Reptile { age: number; isAlive: boolean; } class Worm { isAlive: boolean; } let animal: Animal; let bird: Bird; let fish: Fish; let insect: Insect; let reptile: Reptile; let worm: Worm; let v1: IAnimal = animal; // Ok let v2: IAnimal = bird; // Ok let v3: IAnimal = fish; // Ok let v4: IAnimal = insect; // Ok let v5: IAnimal = reptile; // Ok let v6: IAnimal = worm; // Error

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

ts
class Bird<T> { public name: T; } class Fish<T, S> { public name: T; public age: S; } let v1: Bird<string>; let v2: Bird<number>; let v3: Bird<string> = v2; // Error let v4: Bird<number> = v1; // Error let v5: Bird<string>; let v6: Fish<string, number>; let v7: Bird<string> = v6; // Ok let v8: Fish<string, number> = v5; // Error

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

ts
class Bird { public voice<T>(repeat: T): void {} } class Fish { public voice<T, S>(repeat: T, volume: S): void {} } let v1: Bird let v2: Fish let v3: Bird = v2; // Error let v4: Fish = v1; // Ok

На фоне структурной типизации самое неоднозначное поведение возникает, когда описание типов полностью идентично, за исключением их модификаторов доступа. Если в типе описан хоть один член с отличным от public модификатором доступа, он не будет совместим ни с одним схожим типом, независимо от того, какие модификаторы доступа применены к его описанию.

ts
class Bird { private name: string; } class Fish { private name: string; } class Insect { protected name: string; } class Reptile { public name: string; } let v1: Bird; let v2: Fish; let v3: Insect; let v4: Reptile; let v5: Bird = v2; // Error let v6: Fish = v1; // Error let v7: Insect = v1; // Error let v8: Reptile = v1; // Error

К счастью, разногласия, возникающие в структурной типизации при совместимости типов, представляемых классами, к членам которых применены модификаторы доступа, отличные от public, не распространяются на номинативную типизацию (глава “Экскурс в типизацию - Совместимость типов на основе вида типизации”). Номинативная типизация может указывать на принадлежность к типу через иерархию наследования. Простыми словами, потомки будут совместимы с предками, у которых члены объявлены с помощью модификаторов доступа, отличных от public.

ts
class Bird { protected name: string; } class Owl extends Bird { protected name: string; } let bird: Bird; let owl: Owl; let v1: Bird = owl; // Ok let v2: Owl = bird; // Error let v3: Owl = bird as Owl; // Ok

В типах, определяемых классами, при проверке на совместимость не учитываются конструкторы и статические члены (члены класса).

ts
class Bird { public static readonly DEFAULT_NAME: string = 'bird'; constructor(name: string) {} } class Fish { public static toStringDecor(target: string): string { return `[object ${target}]`; } constructor(age: number) {} } let v1: Bird let v2: Fish let v3: Bird = v2; // Ok let v4: Fish = v1; // Ok

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

ts
class Bird { public name: string; } class Fish { public name: string; public age: number; } class Insect {} let equal: Bird = new Bird(); let more: Fish = new Fish(); let less: Insect = new Insect(); interface IAnimal { name: string; } let v1: IAnimal = new Bird(); // Ok -> одинаковые поля let v2: IAnimal = new Fish(); // Ok -> в Fish полей больше let v3: IAnimal = new Insect(); // Ошибка -> обязательные поля отсутствуют let v4: IAnimal = equal; // Ok -> одинаковые поля let v5: IAnimal = more; // Ok -> в Fish полей больше let v6: IAnimal = less; // Ошибка -> обязательные поля отсутствуют function f1(p1: IAnimal): void {} f1(new Bird()); // Ok -> одинаковые поля f1(new Fish()); // Ok -> в Fish полей больше f1(new Insect()); // обязательные поля отсутствуют f1(equal); // Ok -> одинаковые поля f1(more); // Ok -> в Fish полей больше f1(less); // обязательные поля отсутствуют

Однако, когда в качестве значения выступает объектный тип, созданный с помощью объектного литерала, поведение в некоторых случаях отличается от поведения присвоения экземпляров класса. В тех случаях, в которых объект объявляется непосредственно в операции присвоения, он будет совместим с типом, указанным в аннотации только, если он полностью ему соответствует. Другими словами, создаваемый с помощью литерала объект не должен содержать ни меньше ни больше членов, чем описано в типе указанном в аннотации (данное поведение можно изменить с помощью опции компилятора --suppressExcessPropertyErrors, глава “Опции компилятора”).

ts
interface IAnimal { name: string; } function f1(p1: IAnimal): void {} let equal = { name: '' }; let more = { name: '', age: 0 }; let less = {}; var v1: IAnimal = { name: '' }; // Ok -> одинаковые поля let v2: IAnimal = { name: '', age: 0 }; // Ошибка-> полей больше let v3: IAnimal = {}; // Ошибка -> полей меньше let v4: IAnimal = equal; // Ok -> одинаковые поля let v5: IAnimal = more; // Ok -> полей больше let v6: IAnimal = less; // Ошибка -> полей меньше f1({ name: '' }); // Ok -> одинаковые поля f1({ name: '', age: 0 }); // Ошибка -> больше полей f1({}); // Ошибка -> полей меньше f1(equal); // Ok -> одинаковые поля f1(more); // Ok -> полей больше f1(less); // Ошибка -> полей меньше

Остается только добавить, что выбор в сторону структурной типизации был сделан по причине того, что подобное поведение очень схоже с поведением самого JavaScript, который реализует утиную типизацию. Можно представить удивление Java или C# разработчиков, которые впервые увидят структурную типизацию на примере TypeScript. Сложно представить выражение лица заядлых теоретиков, когда они увидят, что сущность птицы совместима с сущностью рыбы. Но не стоит угнетать ситуацию, выдумывая нереальные примеры, которые из-за структурной типизации приведут к немыслимым последствиям, поскольку вероятность того, что хотя бы один из них найдет олицетворение в реальных проектах настолько мала, что не стоит сил затраченных на их выдумывание.