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

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



В итоге он пришёл к выводу о том, что лучше так не делать. Он подружился с компилятором, начал обращать внимание на его подсказки. Компилятор находит проблемы в коде и сообщает о них задолго до того, как они могут нанести реальный вред. Автор статьи, глядя на себя как на разработчика, понял, что компилятор — это его лучший друг, так как защищает его от него самого. Как тут не вспомнить слова Альбуса Дамблдора: «Требуется большая храбрость, чтобы выступить против своих врагов, но не меньше ее требуется и чтобы выступить против своих друзей».

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

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

Дженерики


Предположим, мы работаем над базой данных некоего учебного заведения. Мы написали очень удобную вспомогательную функцию getBy. Для того чтобы получить объект, представляющий студента, по его имени, мы можем воспользоваться командой вида getBy(model, "name", "Harry"). Взглянем на реализацию этого механизма (тут, чтобы не усложнять код, база данных представлена обычным массивом).

type Student = {
  name: string;
  age: number;
  hasScar: boolean;
};

const students: Student[] = [
  { name: "Harry", age: 17, hasScar: true },
  { name: "Ron", age: 17, hasScar: false },
  { name: "Hermione", age: 16, hasScar: false }
];

function getBy(model, prop, value) {
    return model.filter(item => item[prop] === value)[0]
}

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

function getBy(model: Student[], prop: string, value): Student | null {
    return model.filter(item => item[prop] === value)[0] || null
}


const result = getBy(students, "name", "Hermione") // result: Student

Так наша функция выглядит уже гораздо лучше. Компилятор теперь знает тип ожидаемого от неё результата, это пригодится нам позже. Однако для того, чтобы добиться безопасной работы с типами, мы пожертвовали возможностями повторного использования функции. Что если нам когда-нибудь понадобится применять её для получения каких-то других сущностей? Не может быть, чтобы эту функцию нельзя было бы уже никак улучшить. И это действительно так.

В TypeScript, как и в других языках со строгой типизацией, мы можем использовать дженерики (generics), которые ещё называют «обобщёнными типами», «универсальными типами», «обобщениями».

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

function getBy<T>(model: T[], prop: string, value): T | null {
    return model.filter(item => item[prop] === value)[0]
}

const result = getBy<Student>(students, "name", "Hermione") // result: Student

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

Итак, теперь у нас имеется надёжная вспомогательная функция, подходящая для повторного использования. Однако её ещё можно улучшить. Что если при вводе второго параметра будет сделана ошибка и вместо "name" там окажется "naem"? Функция будет вести себя так, будто искомого студента просто нет в базе, и, что самое неприятное, не выдаст никаких ошибок. Подобное может вылиться в длительную отладку.

Для того чтобы защититься от подобных ошибок, введём ещё один универсальный тип, P. При этом надо, чтобы P был ключом типа T, поэтому, если тут используется тип Student, то нужно, чтобы P представлял бы собой строку "name", "age" или "hasScar". Вот как это сделать.

function getBy<T, P extends keyof T>(model: T[], prop: P, value): T | null {
    return model.filter(item => item[prop] === value)[0] || null
}

const result = getBy(students, "naem", "Hermione")
// Error: Argument of type '"naem"' is not assignable to parameter of type '"name" | "age" | "hasScar"'.

Использование дженериков и ключевого слова keyof — это весьма мощный приём. Если вы пишете программы в IDE, которая поддерживает TypeScript, то, вводя аргументы, вы сможете воспользоваться возможностями автодополнения, что очень удобно.

Однако работу над функцией getBy мы ещё не закончили. У неё есть третий аргумент, тип которого мы пока не задали. Нас это совершенно не устраивает. До сих пор мы не могли заранее знать о том, какого он должен быть типа, так как он зависит от того, что мы передаём в качестве второго аргумента. Но теперь, так как у нас имеется тип P, мы можем динамически вывести тип для третьего аргумента. Типом третьего аргумента, в итоге, будет T[P]. В результате, если T — это Student, a P — это "age", то T[P] будет соответствовать типу number.

function getBy<T, P extends keyof T>(model: T[], prop: P, value: T[P]): T | null {
    return model.filter(item => item[prop] === value)[0] || null
}

const result = getBy(students, "age", "17")
// Error: Argument of type '"17"' is not assignable to parameter of type 'number'.


const anotherResult = getBy(students, "hasScar", "true")
// Error: Argument of type '"true"' is not assignable to parameter of type 'boolean'.


const yetAnotherResult = getBy(students, "name", "Harry")
// А тут уже всё правильно

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

Расширение существующих типов


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

Мы рассмотрим решение подобной проблемы на примере добавления уже известной вам функции getBy в прототип Array. Это позволит нам, пользуясь данной функцией, строить более аккуратные синтаксические конструкции. В настоящий момент мы не говорим о том — хорошо это или плохо — расширять стандартные объекты, так как наша главная цель — изучить рассматриваемый подход.

Если мы попытаемся добавить функцию в прототип Array, то компилятору это очень не понравится:

Array.prototype.getBy = function <T, P extends keyof T>(
    this: T[],
    prop: P,
    value: T[P]
): T | null {
  return this.filter(item => item[prop] === value)[0] || null;
};
// Error: Property 'getBy' does not exist on type 'any[]'.


const bestie = students.getBy("name", "Ron");
// Error: Property 'getBy' does not exist on type 'Student[]'.


const potionsTeacher = (teachers as any).getBy("subject", "Potions")
// Никаких ошибок... но какой ценой?

Если мы попытаемся успокоить компилятор, периодически пользуясь конструкцией as any, то сведём на нет всё, чего добились. Компилятор умолкнет, но о безопасной работе с типами можно будет забыть.

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

Итак, этот код работает:

interface Wand {
  length: number
}

interface Wand {
    core: string
}

const myWand: Wand = { length: 11, core: "phoenix feather" }
// Отлично работает!

А этот — нет:

interface Wand {
  length: number
}

interface Wand {
    length: string
}
// Error: Subsequent property declarations must have the same type.  Property 'length' must be of type 'number', but here has type 'string'.

Теперь, разобравшись с этим, мы видим, что перед нами стоит довольно простая задача. А именно, всё, что нам надо сделать — это объявить интерфейс Array<T> и добавить к нему функцию getBy.

interface Array<T> {
   getBy<P extends keyof T>(prop: P, value: T[P]): T | null;
}

Array.prototype.getBy = function <T, P extends keyof T>(
    this: T[],
    prop: P,
    value: T[P]
): T | null {
  return this.filter(item => item[prop] === value)[0] || null;
};


const bestie = students.getBy("name", "Ron");
// Теперь это работает!

const potionsTeacher = (teachers as any).getBy("subject", "Potions")
// И это тоже работает

Обратите внимание на то, что большую часть кода вы, вероятно, будете писать в файлах модулей, поэтому, чтобы внести изменения в интерфейс Array, вам понадобится доступ к глобальной области видимости. Сделать это можно, поместив определение типа внутрь declare global. Например — так:

declare global {
    interface Array<T> {
        getBy<P extends keyof T>(prop: P, value: T[P]): T | null;
    }
}

Если вы собираетесь расширить интерфейс внешней библиотеки, то вам, скорее всего, понадобится доступ к пространству имён (namespace) этой библиотеки. Вот пример того, как добавить поле userId к Request из библиотеки Express:

declare global {
  namespace Express {
    interface Request {
      userId: string;
    }
  }
}

Поэкспериментировать с кодом из этого раздела можно здесь.

Итоги


В этом материале мы рассмотрели методики использования дженериков и расширения типов в TypeScript. Надеемся то, что вы сегодня узнали, поможет вам в написании надёжного, понятного и типобезопасного кода.

Уважаемые читатели! Как вы относитесь к типу any в TypeScript?

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


  1. ilitaexperta
    17.10.2018 13:09
    -1

    Что за элементарщина, эта инфа достойна странички в справочнике по языку, а не целой статьи


    1. kovalevsky
      17.10.2018 13:22

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


  1. Yeah
    17.10.2018 14:08
    -2

    1. mayorovp
      17.10.2018 14:21

      Добавьте классам хоть какие-нибудь поля и будет вам щастье


      1. Yeah
        17.10.2018 14:24

        Замечательно: вот вам инструмент по типизации, но он будет работать только если в классах будут поля. Я неслучайно добавил последнюю строку в пример: instanceof из самого обычного JS почему-то и без полей прекрасно знает, что это объекты разных типов, а вот "типизированный" TS — нет


        1. mayorovp
          17.10.2018 14:28

          А в чем, собственно, проблема? Какая в принципе может быть разница Cat там передан или Dog, если у них один и тот же интерфейс?


          1. Yeah
            17.10.2018 14:34

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


            1. mayorovp
              17.10.2018 14:36
              +3

              Инструмент обещает структурную типизацию и ее же обеспечивает.


              1. JPEG
                18.10.2018 00:31

                Для классов хорошо бы иметь номинальную типизацию. И, вроде, так и есть в ТС.


          1. kemsky
            18.10.2018 15:09

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


        1. CoreTeamTech
          17.10.2018 16:33

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

          function woo(dog: Dog) {...}
          
          woo((serverResult: Dog));
          woo((cat: Dog));
          


          1. JPEG
            18.10.2018 00:33

            Нет, не кастов не встречал. Но я и не очень много классов встречаю в последнее время, всё больше функции, функции.


    1. emerido
      17.10.2018 15:30
      +1

      А что вы хотели от валидного с точки зрения типизации примера
      Выполнение этого кода безопасное и будет всегда false


      1. Yeah
        17.10.2018 17:25

        Это нифига не безопасный пример и код. Представим себе, что у нас там не Cat и Dog, а Date и Disk, и у каждого по методу format. Нужно пояснять, что произойдет?


        function lol(date: Date) {
          console.log(date.format());
        }
        lol(new Disk())


        1. Shtucer
          17.10.2018 17:34

          Придётся представить, что у Date и Disk общий предок, а я не могу себе такое представить.


          1. rraderio
            18.10.2018 08:35

            Object, Any?


          1. kemsky
            18.10.2018 16:03

            Это неудачный пример, у вас могут быть две разные сущности, которые имеют тип {id:number, name:string} и два соотв. сервиса чтобы их сохранять на бэкенде, компилятор разрешит вам сохранить любую сущность любым сервисом, что будет ошибкой. Люди не знакомые с тайпскриптом пытаются использовать маркерные (пустые) интерфейсы, что тоже приводит к похожим ошибкам.


        1. emerido
          17.10.2018 18:43

          Ну давайте по порядку

          1. Мой ответ был к примеру кода где в минус было приписано что код

          console.log(new Cat() instanceof Dog)

          Должен почему-то быть не валидным с точки зрения анализатора, но это не так.
          Ну попробуйте сделать тоже самое например в C# (там вы получите уведомление что условие всегда будет ложным)

          2. Приведенный вами пример будет не валидным. Предлагаю вам попробовать не в онлайн редакторе


        1. ddanilovich
          17.10.2018 20:11
          +1

          www.typescriptlang.org/docs/handbook/type-compatibility.html

          The basic rule for TypeScript’s structural type system is that x is compatible with y if y has at least the same members as x.

          Для того, чтобы ваш код был валидным, нужно что бы в классе Disk как минимум были объявлены поля и методы с теми же типами, что и у Date.


    1. fillpackart
      18.10.2018 04:02

      Так это как бы фича. Структурная типизация.


    1. aleshadk
      18.10.2018 14:48

      Возможно я скапитаню, но это называется номинативная и структурная типизация.
      Меня например смущает в C#, что необходимо явно указывать интерфейс, даже если структура ему полностью соответствует
      interface INamed {
      name: string;
      }

      class MyClass {
      public name: string: 'My Class name';
      }

      function logName(data: INamed): void {
      console.log(data.name);
      }

      logName(new MyClass()); // В язык с номинативной типизацией компилятор будет ругаться


      В тайпскрипт такой проблемы не будет, но! Эта вся история работает в обе стороны, что указано в твоём примере, не думаю что это проблема конкретно тайпскрипта:
      class MyClass {
      public name: string = 'My Class name';
      }

      function processMyClass(data: MyClass): void {
      //
      }

      processMyClass({name: 'another string'});


      И да, как писали выше решается это через что-то вроде private readonly __classGuard: 'MyClass' = 'MyClass';


  1. JustDont
    17.10.2018 19:17

    Тайпскрипт прекрасен, но на практике даже больше половины этой прекрасности вдребезги разбивается об суровую реальность окружения. То есть, всего стороннего, начиная от самой инфраструктуры ECMAScript — уже там есть случаи, когда со строгими типами не всё безоблачно. Да, есть @types, вот только никто не гарантирует, что файлы деклараций для того или иного фреймворка или либы будут написаны хорошо, то есть так, чтоб ими можно было пользоваться без всё возрастающего желания послать всё на три буквы и переписать типы на any.

    И в итоге приходим к тому, что или в код пробивается некоторое количество any, или код будет снабжен интерфейсными «затычками», не делающими ничего полезного, кроме локализации any. И то и другое — прямо скажем, посредственные исходы.


    1. nico
      17.10.2018 22:39

      Если найдёте ошибку в types, кто вам мешает открыть pr и наслаждаться жизнью дальше?


      1. JustDont
        17.10.2018 22:45

        Дело не в ошибках, а в подходах. Скажем, видели ли вы @types/d3? Я вот честно пытался влезть в объявленные там типы и структуры, недельку примерно. Потом плюнул и написал прослойку, которая бы стыковалась с нашим проектом, не вызывая при этом приступы мигрени. А в d3 всё уходило-приходило через any.


  1. Dzenly
    18.10.2018 13:32

    function getBy(model, prop, value) {
        return model.filter(item => item[prop] === value)[0]
    }

    Я бы заменил filter на find. И убрал бы [0].
    Без необходимости, ни к чему ресурсы кушать. Да и кода меньше.