Меня зовут Дима. Я Frontend разработчик в компании fuse8. Работая с TypeScript, рано или поздно сталкиваешься с вопросом: что выбрать — типы или интерфейсы? В нашей команде мы активно используем TypeScript, уделяя особое внимание типам. В статье я хотел бы поделиться особенностями работы с типами и интерфейсами, которые могут быть полезны в вашей практике.

Основные отличия типов и интерфейсов

Типы используются для задания именованных типов данных, включая примитивы, объекты, функции и массивы. Они позволяют объединять или пересекать типы и поддерживают использование ключевых слов typeof, keyof при присвоении.

Интерфейсы служат для описания структуры объектов. Интерфейсы поддерживают декларативное объединение и могут быть расширены другими интерфейсами или классами.

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

Для примитивов и кортежей используйте типы

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

Пример с примитивами:

type UserId = string;
type ColumnHeight = number;
type isActive = boolean;

В интерфейсах примитивные типы можно использовать в описании свойств объектов:

interface User {
  id: string;
  age: number;
  isActive: boolean;
}

Пример с кортежем:

type Coordinates = [number, number];

Добиться похожего поведения можно и с помощью интерфейса, но так не рекомендуется делать:

 interface Coordinates {
     0: number;
     1: number;
     length: 2; // фиксированная длина
 }

Интерфейсы с одинаковыми именами объединяются

Интерфейсы обладают особенностью, которая отсутствует у типов: если у вас есть несколько интерфейсов с одинаковыми именами, они могут объединяться. Это особенно полезно, когда вы работаете с внешними библиотеками или проектами, где структуру объекта нужно расширять.

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

interface User {
  id: number;
}

interface User {
  name: string;
}

const user: User = {
  id: 100,
  name: 'John Doe'
};

В этом примере два интерфейса User сливаются в один, который содержит оба свойства: id и name. Это позволяет гибко добавлять новые поля к уже существующим структурам, не трогая оригинальный код. Если бы вы пытались сделать то же самое с типами, TypeScript выдал бы ошибку — названия типов должны быть уникальными, даже если типы находилсь находились бы в разных файлах.

Объединение происходит не на уровне одного файла, а на уровне всего проекта. Поэтому важно помнить, особенно, если проект большой, что есть возможность случайно расширить уже существующий интерфейс. Также это правило работает для предустановленных интерфейсов, например, если нужно затипизировать комментарий с помощью интерфейса, выбрав название Comment, то мы расширим интерфейс Comment, который находится в lib.dom.d.ts.

Для большего погружения можно ознакомиться с документацией по объединению интерфейсов.

Типы можно пересекать и объединять, интерфейсы – наследовать

Пересечение типов осуществляется с помощью оператора &:

type User = { id: string; };
type Article = { title: string; };

type UserArticle = User & Article;

Здесь UserArticle объединяет свойства как пользователя, так и статьи.

Похожего поведения в интерфейсах можно добиться с помощью ключевого слова extends:

interface User {
 id: string;
}

interface Article {
 title: string;
}

interface UserArticle extends User, Article {}

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

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

Однако реальные тесты показывают, что разница незначительна. Например, проверка 10 тысяч одинаковых конструкций для интерфейсов и типов не выявила существенной разницы в скорости компиляции. Эксперимент можно найти здесь.

Другое отличие заключается в том, что если оба типа являются объектами, и в этих объектах содержатся поля с одинаковыми названиями, но разными типами, то extends выдаст ошибку, а при использовании& ошибки не будет. Рассмотрим пример:

type User = {
 id: string;
}

type Article = {
 id: number;
}

type UserArticle = User & Article;

В UserArticle ошибки нет, но id имеет тип never, так как id не может быть одновременно и строкой и числом. А при использовании extends получаем ошибку:

Типы также поддерживают объединение с помощью оператора |. Это удобно, когда тип может быть один из нескольких вариантов:

type User = {
 id: string;
}

interface Article {
 title: string;
}

type ProductId = string;

type Payload = User | Article | ProductId;

Лаконичность типов при использовании Utility Types

Типы имеют более более лаконичный синтаксис при использовании Utility Types, чем интерфейсы. Например, для создания типа с необязательными полями можно воспользоваться утилитой Partial.

Вот как это выглядит для типов:

type User = {
  id: string;
}

type UserPartial = Partial<User>;

Теперь давайте посмотрим, как это будет выглядеть с интерфейсом:

interface User {
  id: string;
}

interface UserPartial extends Partial<User> {}

В случае с интерфейсом нам приходится добавлять дополнительные конструкции extends и пустые фигурные скобки {}, что делает код менее читабельным. Это не критично, но может добавлять лишний «шум», особенно если часто используются такие утилиты как Partial, Pick, Omit и другие.

Свойства интерфейсов сохраняют источник

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

Пример:

interface User {
  id: string;
}

interface Article {
  name: string;
}

interface UserArticle extends User, Article {};

const userArticle: UserArticle = {
  id: 'test',
  name: 'test'
};

Если вы посмотрите на объект userArticle, поле id будет связано с User.id: string, а name — с Article.name: string. Это может помочь лучше понять, откуда взято конкретное свойство при сложных наследованиях.

Теперь давайте перепишем тот же пример на типах:

type User = {
  id: string;
}

type Article = {
  name: string;
}

type UserArticle = User & Article;

const userArticle: UserArticle = {
  id: 'test',
  name: 'test'
};

В случае с типами при отладке оба поля id и name будут просто строками (string), и информация о том, откуда они взяты, будет потеряна.

Когда использовать типы, а когда интерфейсы?

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

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

Полезные ссылки:

Комментарии (23)


  1. 19Zb84
    21.09.2024 03:39

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

    Интерфейсы нужны для взамодействия пользователя с кодом. Как любой сайт - это интерфейс программы.

    Типы это описание передаваемых данных в коде.

    Пример. Посмотрите структуру документации libp2p


    1. Mingun
      21.09.2024 03:39
      +3

      Ваше описание слишком расплывчато. Что значит «взаимодействие пользователя с кодом»? Пользователь у нас кто? Программист. И что же за взаимодействие с кодом может быть у программиста? Создание/изменение структур данных и функций, их обрабатывающих. А для этого эти самые структуры надо как-то описать. Вот и пришли к вашему определению типов.

      С другой стороны, «описание передаваемых данных» само по себе в вакууме не нужно. Оно есть для того, чтобы мы понимали, что с этими данными можно делать, а чего нельзя. А это — работа с данными. Какого рода работа? Преобразование, передача в функции, возврат из функций. Всё это есть взаимодействие с кодом. Приходим к вашему описанию интерфейсов.

      Получается, типы и интерфейсы — одно и то же?


      1. 19Zb84
        21.09.2024 03:39

        Ваше описание слишком расплывчато.

        Что бы описать интерфейс вы должны понимать приложение, которое описывается.
        Если вы не понимаете что описываете вы не сможете составить интрефейс.

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

        libp2p про которую я написал как раз уже за много лет определились с этим. У них очень небольшое колличества документации. Все сделанно через typescript docs

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

        Интерфейс это тот объект который я физически составляю. (пишу. Точки входа в библиотеку. Как розетка )
        Типы это то что я использую для составления интерфейса.

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

        interface Libp2pInit<T> {
            addresses?: AddressManagerInit;
            connectionEncrypters?: ((components) => ConnectionEncrypter<unknown>)[];
            connectionGater?: ConnectionGater;
            connectionManager?: ConnectionManagerInit;
            connectionMonitor?: ConnectionMonitorInit;
            contentRouters?: ((components) => ContentRouting)[];
            datastore?: Datastore<{}, {}, {}, {}, {}, {}, {}, {}, {}, {}>;
            dns?: DNS;
            logger?: ComponentLogger;
            nodeInfo?: NodeInfo;
            peerDiscovery?: ((components) => PeerDiscovery)[];
            peerRouters?: ((components) => PeerRouting)[];
            peerStore?: PersistentPeerStoreInit;
            privateKey?: PrivateKey;
            services?: ServiceFactoryMap<T>;
            streamMuxers?: ((components) => StreamMuxerFactory)[];
            transportManager?: TransportManagerInit;
            transports?: ((components) => Transport<ProgressEvent<any, unknown>>)[];
            connectionProtector?(components): ConnectionProtector;
            metrics?(components): Metrics;
        }
        

        типы и интерфейсы — одно и то же?

        Ну вообще да. Можно одно через другое описать.

        https://libp2p.github.io/js-libp2p/modules/libp2p.index.html


        1. Mingun
          21.09.2024 03:39
          +6

          Непонятно, что поменяется для вас, как для пользователя библиотеки, если вместо interface Libp2pInit<T> { будет type Libp2pInit<T> = {.


          1. 19Zb84
            21.09.2024 03:39

            А что для вас поменяется ?


            1. Mingun
              21.09.2024 03:39

              Для меня — пока ничего, но я как раз и пытаюсь выяснить, что мне следует использовать в своих проектах на TypeScript — type или interface? Потому что пока ясности нет, зачем нужны оба, делают вроде одно и тоже, просто чуть по-разному.


              1. 19Zb84
                21.09.2024 03:39


                Если вы говорите о небольшом проекте, то там typeScript вообще не нужен. Это графоманство. имхо.

                Если это сложный проект как эта библиотека, то это сильно влияет на потраченное время.

                Я либо потрачу 10 минут на поиски информации либо часа 4.

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

                Это основное что ля меня важно.


                1. Mingun
                  21.09.2024 03:39

                  Не понял, почему с типами вы потратите 4 часа на поиск информации, если с интерфейсами вы тратите на это 10 минут?


                1. Mingun
                  21.09.2024 03:39

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


                1. gsaw
                  21.09.2024 03:39

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

                  На прошлой неделе переводил свой старый, относительно небольшой проект с JS на TS. В некоторых местах даже удивлялся, как оно вообще работало. :) Там такие смешные ошибки были в коде, которые без типов были просто не видны. Просто опечатки, пропущенные параметры.

                  Да и вообще, когда просто ide делает подсказки уже ускоряет работу с кодом. Тот же GitHub Copilot лучше ориентируется в коде. Нет нужды открывать файл с функцией, что бы понять, что за структуру она возвращает.

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

                  Короче выигрыш от описания типов больше, чем затраты на описание типов.


            1. meonsou
              21.09.2024 03:39
              +1

              Даже если в конкретном случае не поменяется ничего, как минимум кодовая база станет однороднее, а значит понятнее. Вот есть 2 почти одинаковых инструмента с редкими практическими различиями, я предпочитаю по этим различиям и проводить границу. Либо использовать интерфейсы всегда, кроме случаев когда нужны типы, либо наоборот. Когда начинаются разговоры про "взаимодействие пользователя с кодом" и "описание данных" это уже какая-то субъективная ерунда безосновательная. Вот как, например, новый разработчик в вашей команде поймёт когда ему использовать что? Вы можете сформулировать чёткое и однозначное определение своих терминов? А если и можете, будет ли оно проще и будет ли эта сложность того стоить?

              Что касается выбора между "всегда типы пока не нужны интерфейсы" и "всегда интерфейсы пока не нужны типы" (где граница предельно ясна), я предпочитаю первое, потому что типы работают проще и интуитивнее. С интерфейсами могут потом ещё возникнуть проблемы и окажется что надо переписывать на типы, с типами такое случается реже.


    1. iliazeus
      21.09.2024 03:39

      Я понимаю, что по заголовку статьи это не так понятно, но в ней идет речь именно про ключевые слова type и interface в TypeScript.


  1. NeoCode
    21.09.2024 03:39
    +1

    Какой интересный язык! Кажется в работе с типами он продвинулся дальше многих других современных языков.

    Кстати получается что пересечение & это по сути теоретико-множественное объединение, а объединение | это "тип-сумма" (tagged union, variant). А вот интересно, можно в нем сделать теоретико-множественное пересечение, разность или симметрическую разность типов?



    1. meonsou
      21.09.2024 03:39
      +1

      Объединение это не тип-сумма. Tagged union это дизъюнктивное объединение из теории множеств, а union это обычное объединение. Ну а & это пересечение, а не объединение.


      1. NeoCode
        21.09.2024 03:39

        ИМХО в теории множеств вообще нет аналога для Tagged union. Потому что в tagged union нельзя хранить разные элементы одновременно, а в теории множеств про "одновременность" вообще ничего не говорится.

        По поводу пересечения & - вот пример из интернета

        type User = {
          name: string;
          age: number;
        };
        
        type Employee = {
          name: string;
          department: string;
          salary: number;
        };
        
        type CommonUserEmployee = User & Employee;
        // Result: { name: string; age: number; department: string; salary: number; }

        т.е. на выходе мы имеем тип, объединяющий поля из двух типов, причем одинаковые поля сливаются - в точности как в объединении множеств. A={1,2}, B={2,3}, A∪B={1,2,3}.


        1. meonsou
          21.09.2024 03:39

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

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


          1. NeoCode
            21.09.2024 03:39

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

            Тогда понятно

            а у типа-произведения, соответственно, произведение.

            Т.е. здесь имеется в виду декартово произведение

            Про разные элементы одновременно не понял что вообще имелось в виду

            Я просто пытался рассматривать типы как множества полей структур, а не как множества значений.


    1. iliazeus
      21.09.2024 03:39

      A & B - это объединение известных утверждений про типы A и B (вида "у этого типа есть поле А), но пересечение множеств значений этих типов.

      Разность множеств значений можно выразить через встроенный тип Exclude<A, B>. Симметрическая разность - это, соответственно, Exclude<A | B, A & B>.


    1. iliazeus
      21.09.2024 03:39

      Кажется в работе с типами он продвинулся дальше многих других современных языков.

      Насколько понимаю, это было вынужденно - нужно было натянуть статические типы на очень динамические интерфейсы уже существующих JS-библиотек.


    1. devunion
      21.09.2024 03:39

      Теоретически широкие возможности -- это хорошо. А на практике в результате такой код получается, что начинаешь думать, что лучше бы этих возможностей не было .


  1. Akuma
    21.09.2024 03:39
    +1

    Вообще просто не использую интерфейсы. Ну ли о так редко, что даже не помню.

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


  1. Rewwoken
    21.09.2024 03:39

    Вы говорите не про типы, а алиасы типов (type alias)