Хабрахабр

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

Автор статьи, перевод которой мы сегодня публикуем, говорит, что 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: "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 был ключом типа T, поэтому, если тут используется тип Student, то нужно, чтобы P представлял бы собой строку "name", "age" или "hasScar". Для того чтобы защититься от подобных ошибок, введём ещё один универсальный тип, P. Вот как это сделать.

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 мы ещё не закончили. До сих пор мы не могли заранее знать о том, какого он должен быть типа, так как он зависит от того, что мы передаём в качестве второго аргумента. Нас это совершенно не устраивает. Типом третьего аргумента, в итоге, будет T[P]. Но теперь, так как у нас имеется тип 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?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть