Партизанские типы в TypeScript
Повстанческий дизайн типов
Партизанские типы в TypeScript
В этой статье мы рассмотрим три интригующих (возможно, ужасных?) приёма для помощи в дизайне типов!
Главная цель — согласованные и предсказуемые интерфейсы моделей/сущностей/классов.
- Подходы к проектированию типов
- Приём №1: Зачем выбирать?
- Приём №2: Миксины
- Приём №3: Организация с помощью пространств имён
- Итоги
Подходы к проектированию типов
Вы, вероятно, сталкивались с различными паттернами «реализации типов». Особенно при потреблении данных от сторонних API.
Примечание: Я намеренно игнорирую «традиционные» процессы построения диаграмм связей сущностей (ERD) или иерархий наследования в объектно-ориентированном программировании (ООП). Здесь мы создаём типы для представления полуструктурированных данных API.
Давайте рассмотрим два высокоуровневых подхода: один большой объект (сверху вниз) против нескольких именованных типов (снизу вверх).
Один большой объект
Приоритет отдаётся явности, а не переиспользованию и DRY.
Бонус: Отличный опыт разработки в IDE, поскольку подсказки содержат более полный предварительный просмотр — без лишних телодвижений.
interface ProductDetails { name: string; seller: { name: string }; availability: Array<{ warehouseId: string; quantity: number }>; reviews: Array<{ authorId: number; stars: number }>;}Поскольку мы отдаём приоритет явной читаемости, можно позволить себе немного повторений (в пределах разумного). Когда группы свойств повторяются много раз, не стесняйтесь извлекать повторяющиеся поля в именованный тип.
Несколько именованных типов
Приоритет переиспользования и DRY.
Этот подход, вероятно, является наиболее распространённым по широкому отрыву.
interface ProductDetails { name: string; seller: Seller; reviews: Reviews[]; availability: Availability[];}interface Seller { name: string; }interface Availability { warehouseId: string; quantity: number; }interface Reviews { authorId: number; stars: number; }В целом, этот подход отличный. Но у него есть недостатки.
- Читаемость отлична на первых порах; однако она может ухудшаться по мере роста размера и количества типов.
- Безжалостный DRY, но какой ценой? (Подробнее об этом позже.)
- Опыт разработчика страдает, поскольку подсказки менее информативны.
⚠️ Начиная с (примерно) TypeScript v3, языковой сервер truncates подсказки, опуская вложенные свойства. 💡 Есть способы немного улучшить ситуацию. Попробуйте удерживать
Cmd или Ctrl, затем наведите курсор на различные имена типов — вы должны увидеть хотя бы один дополнительный «слой» свойств в подсказке.
Зачем нам выбирать между этими двумя подходами? (Большой тип против именованных подтипов.)
Приём №1: Зачем выбирать?
Разве мы не можем получить всё?
- Ясность «типов общей картины»?
- Плюс именованные подтипы?
- Без дублирования?
✅ ДА! 🎉
export interface ProductDetails { name: string; seller: { name: string }; reviews: Array<{ authorId: number; stars: number }>; availability: Array<{ warehouseId: string; quantity: number }>;}export type Seller = ProductDetails["seller"];export type Review = ProductDetails["reviews"][number];export type Availability = ProductDetails["availability"][number];- Создайте большие «основные» структурированные типы.
- Экспортируйте подтипы, выведенные из основного типа.
Этот подход действительно сияет в системах, где «высокоуровневые» объекты выигрывают от документации в одном месте. Кроме того, эта техника поддерживает переиспользование между множеством случаев использования: модели, сервисы, результаты запросов и т.д.
Приём №2: Миксины
Эта стратегия заключается в сочетании правильных полей с правильными именами для представления отдельных логических объектов. Цель — эффективно решить несколько случаев использования с помощью утилит TypeScript и объединений типов.
Этот подход отличается от традиционного наследования ООП и иерархий, которые стремятся создать слои объектов в тесно связанные таксономии. Подход с миксинами — это плоские и слабо связанные типы, группирующие связанные поля при уменьшении дублирования.
Примеры миксинов
interface TodoModel { text: string; complete: boolean;}interface InstanceMixin { id: number;}/** TodoDraft представляет состояние формы, возможно, всё undefined */export type TodoDraft = Partial<TodoModel>;/** Todo представляет запись экземпляра Todo из базы данных */export type Todo = TodoModel & InstanceMixin;Пример User
interface User { id: number; name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}Давайте представим User до и после сохранения в базу данных.
// Основные поля User (скажем, для <form>)interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Поля из базы данныхinterface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}// Экземпляр **User** — со всеми полямиtype UserInstance = InstanceMixin & UserBase;Теперь мы можем вылепить именно те поля, которые нам нужны (например, password для создания/обновления, но не включённые в запросы UserInstance).
interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}interface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}/** Полезная нагрузка User для регистрации, включая поле `password` */export type UserPayload = UserBase & { password: string };/** Представляет тип User, возвращаемый с сервера. */export type UserInstance = UserBase & InstanceMixin;- «Это хорошая практика?»
- «Стоит ли мне попробовать?»
Понятия не имею. Продолжим!
Приём №3: Организация с помощью пространств имён
Здесь мы объявляем пространство имён ModelMixins. Это обеспечивает некоторую организацию и более понятный паттерн переиспользования.
Стандартизированные формы
createdAtиupdatedAtсуществуют вместе.id, а неIDили_id.
// `src/types/mixins.d.ts`namespace ModelMixins { interface Identity { id: number; } interface Timestamp { createdAt: Date; updatedAt: Date; } type Instance = ModelMixins.Identity & ModelMixins.Timestamp; interface HashedPassword { passwordHash: string; } interface InputPassword { password: string; }}Использование объединений типов
// `src/types/user.d.ts`export interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Единый тип `User`, использующий объединение типов для динамического// представления состояний до и после создания.export type User = | (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword) | (UserBase & ModelMixins.InputPassword);При желании вы также можете экспортировать отдельные именованные типы:
/** Полезная нагрузка User для регистрации, включая поле `password` */export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;/** Представляет тип User, возвращаемый с сервера. */export type UserInstance = UserBase & ModelMixins.InputPassword;Использование в реальном мире
Вот функция upsert(), которая использует оператор in для различения типов UserInstance и UserPayload.
function upsert(user: User) { if ("id" in user) { // TypeScript знает, что `user` здесь имеет поля из Instance (id, createdAt и т.д.) return updateUser(user.id, user); } else { // TypeScript знает, что это должна быть версия user `UserBase & ModelMixins.InputPassword`. return createUser(user); }}Итоги
Мы рассмотрели три приёма и несколько связанных поддерживающих идей.
Возможно, вы спрашиваете: это хорошие паттерны? Стоит ли мне adoptar некоторые из этих идей?
Ресурсы
- Советы по TypeScript для легаси-проектов: Type only you need
- Отличная новая книга Мэтта Покока
- Советы Total TypeScript