DanLevy.net

Партизанские типы в TypeScript

Повстанческий дизайн типов

Hero image for Партизанские типы в TypeScript

Партизанские типы в TypeScript

В этой статье мы рассмотрим три интригующих (возможно, ужасных?) приёма для помощи в дизайне типов!

Главная цель — согласованные и предсказуемые интерфейсы моделей/сущностей/классов.

Подходы к проектированию типов

Вы, вероятно, сталкивались с различными паттернами «реализации типов». Особенно при потреблении данных от сторонних 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; }

В целом, этот подход отличный. Но у него есть недостатки.

⚠️ Начиная с (примерно) 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];
  1. Создайте большие «основные» структурированные типы.
  2. Экспортируйте подтипы, выведенные из основного типа.

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

Приём №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;
  1. «Это хорошая практика?»
  2. «Стоит ли мне попробовать?»

Понятия не имею. Продолжим!

Приём №3: Организация с помощью пространств имён

Здесь мы объявляем пространство имён ModelMixins. Это обеспечивает некоторую организацию и более понятный паттерн переиспользования.

Стандартизированные формы

// `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 некоторые из этих идей?

Ресурсы