Выживание в экосистеме TypeScript - Часть 3: Интерфейсы и структурная типизация

Источник на Github: Демонстрация TypeScript

Небольшое примечание, прежде чем мы перейдем к этому: все примеры в этом посте используют TypeScript v2.9.1. Если вы видите другое поведение, проверьте свою версию. Время от времени я буду стараться обновлять примеры с помощью обновлений TypeScript.

Вступление

Это продолжение серии статей о написании типобезопасного кода JavaScript с помощью TypeScript. В этом посте мы рассмотрим наиболее существенные отличия системы типов TypeScript от того, к чему, вероятно, привыкло большинство разработчиков. Если вы хотите следить за этой серией с самого начала, ознакомьтесь с Написанием безопасного по типу (ish) кода JavaScript.

Структурная типизация?

Одним из отличий TypeScript от большинства объектно-ориентированных языков со статической типизацией является то, что он использовал преимущественно систему структурных типов. Что это значит?

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

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

Рассматривая пример на Java, предположим, что у нас есть два интерфейса, которые выглядят одинаково.

public static interface IPerson {
    public String getName();
}
public static interface IEmployee {
    public String getName();
}

Теперь давайте реализуем два класса на основе этих интерфейсов.

public static class Person implements IPerson {
    private final String name;
    public Person(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
}
public static class Employee implements IEmployee {
    private final String name;
    public Person(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
}

Эти два интерфейса и эти два класса абсолютно одинаковы, за исключением имен. Однако в Java они не взаимозаменяемы.

public class NominalTyping {
    public static void main(String[] args) {
        final IPerson person = new Employee("John");
        System.out.println("Name: " + person.getName());
    }
}

Это не работает в Java. Однако в TypeScript это было бы хорошо, потому что все, что имеет значение, - это структура типа, а не имя типа. Если два типа конструктивно эквивалентны, они взаимозаменяемы.

Структурная типизация на примере

$ git checkout structural-typing

Давайте посмотрим на пример. Допустим, у нас есть два эквивалентных интерфейса ...

interface IPerson {
    name: string
}
interface IEmployee {
    name: string
}

Что касается TypeScript, это фактически один и тот же тип. TypeScript не волнует, что у них разные имена.

function getName(obj: IPerson): string {
    return obj.name
}
const user: IPerson = {
    name: 'Larry Sanders',
}
const employee: IEmployee = user
const employee2: IEmployee = {
    name: 'Bob Marley',
}
getName(employee2)

Это нормально.

Однако вы можете не ожидать, что если мы скажем, что что-то является IPerson, у него есть имя и возраст, но также есть возраст и адрес электронной почты.

const person: IPerson = {
    name: 'Carly Simon'
    age: 72,
    email: '[email protected]'
}

На самом деле это будет ошибкой.

$ npm run build
Object literal may only specify known properties

Итак, TypeScript не допускает дополнительных свойств? Здесь все становится немного противоречивым.

interface IPersonDesc {
    age: number
    email: string
}
const person: IPerson & IPersonDesc = {
    name: 'Carly Simon',
    age: 72,
    email: '[email protected]',
}
const person2: IPerson = person

Хорошо, спойлер, эта версия отлично компилируется. Давайте рассмотрим. Прежде всего мы перейдем к середине. Оператор «&», что он делает. Это создает тип пересечения. Тип пересечения берет все свойства / методы из интерфейса слева и добавляет их к свойствам интерфейса справа и создает новый тип, который имеет все свойства / методы обоих.

Вернуться к началу. Мы определяем новый интерфейс, который имеет дополнительные свойства, которые мы хотели использовать в дополнение к тому, что уже было доступно в интерфейсе IPerson. Затем мы определили объект person как обладающий всеми свойствами, используя оператор пересечения, как IPerson, так и IPersonDesc. Это позволяет нам определить объект, который мы пытались определить ранее.

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

В связи с этим давайте рассмотрим еще один пример, которого мы не ожидали.

interface IUser {
    name: string
}
const names: Array<string> = [ 'Bart', 'Homer', 'Marge', 'Lisa' ]
const users: Array<IUser> = names.map((name, index) => ({
   name,
   id: index
}))

Опять же, это нормально компилируется. Мы создаем массив IUser, но все объекты, которые мы помещаем в массив, имеют дополнительное свойство. Увидев, что это работает, попробуйте явно определить тип возвращаемого значения функции карты.

const users: Array<IUser> = names.map((name, index): IUser => ({
    name,
    id: index,
}))

Это не сработает.

$ npm run build
Object literal may only specify known properties

Хорошо, так что здесь происходит? Ну, прежде чем мы явно сказали, что функция карты вернула IUser TypeScript, нужно было сделать вывод о типе. Он предполагает, что этот тип является интерфейсом со свойством name, которое является строкой, и свойством id, которое является числом. Хорошая работа TypeScript. Совершенно верно. Когда TypeScript определяет этот интерфейс, он делает еще один шаг вперед и делает вывод, что он, по сути, является подтипом (или пересечением) типа IUser. Это кажется немного непоследовательным, но это то, что есть.

Заключение

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

Еще статьи из этой серии

Дальнейшее чтение