Школа магии TypeScript: дженерики и расширение типов

в 9:53, , рубрики: javascript, TypeScript, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

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

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

Школа магии TypeScript: дженерики и расширение типов - 1

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

Каким бы хорошим другом ни был компилятор, ему не всегда легко угодить. Иногда избежать использования типа 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?

Автор: ru_vds

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js