Типизируем CRM · Модель вместо набора строк
В поиске CRM появилась строка: kind: 'company', id: 'P_42'. Интерфейс рисует компанию, а запрос за подробностями уходит за человеком. Оба поля имеют тип string, поэтому компилятор молчит.
В предыдущем уроке ты подключил проверку типов. Теперь нужно дать ей предмет проверки. Модель должна описывать связи и намерения, а не только форму увиденного JSON. Иначе вместо JavaScript с багом получится TypeScript с тем же багом.
Это продолжение практикума по записи занятия. В записи нет исходного репозитория: примеры ниже восстановлены для урока. Поля, префиксы и правила сделки здесь составляют явный учебный договор, а не утверждение о неизвестной рабочей системе.
1. Сначала выясни, что означает поле
В CRM есть люди, компании, сделки и заметки. Человек и компания показываются по имени. У сделки есть заголовок, сумма, ответственный и контакты. Заметка относится к одному из трёх объектов.
Начальная аннотация выглядит так:
type LooseEntity = {
id: string;
kind: string;
name: string;
};
Она ловит name: 123, но пропускает незнакомый kind и чужой идентификатор. Прежде чем сужать тип, задай вопросы:
- Что различает человека и компанию?
- Ответственный за сделку всегда человек или ещё и компания?
nullу почты означает отсутствие почты или ещё не загруженные данные?- Поле
customсодержит произвольный JSON или определённый договором набор значений?
Исходный код даёт гипотезы, не ответы за бизнес. Если в старых данных встречается числовой id, не превращай его в строку ради зелёного редактора. Это может быть ошибка данных, другой формат API или неверная гипотеза о модели.
2. Идентификатор показывает связь
В учебной CRM префиксы различают виды объектов:
export type PrefixedId<P extends string> = `${P}_${string}`;
export type PersonId = PrefixedId<'P'>;
export type CompanyId = PrefixedId<'C'>;
export type DealId = PrefixedId<'D'>;
export type NoteId = PrefixedId<'N'>;
export function makeId<P extends string>(prefix: P): PrefixedId<P> {
return `${prefix}_${crypto.randomUUID()}` as const;
}
const personId = makeId('P'); // `P_${string}`
const companyId = makeId('C'); // `C_${string}`
Параметр типа P сохраняет связь между аргументом и результатом. Если принять prefix: string, знание о конкретном префиксе потеряется. Если сама переменная префикса имеет тип string, дженерик тоже не угадает её содержимое.
Шаблонный строковый тип описывает формат, но не проверяет строку во время выполнения. Он допускает даже 'P_' и не доказывает, что такой человек существует. Это не номинальный бренд: две модели с одинаковым префиксом будут совместимы. Для более сильного различения вернись к брендированным типам.
Теперь ownerId: PersonId сообщает больше, чем ownerId: string: ответственный связан с человеком. Person['id'] тоже выражает эту связь. В типовом контексте поле читается через квадратные скобки, не через Person.id.
3. Общая форма не заменяет варианты
Сохрани эти объявления вместе с идентификаторами в shared/model.ts:
type NamedEntity<P extends string> = {
id: PrefixedId<P>;
name: string;
createdAt: string;
updatedAt: string;
tags: string[];
custom: Record<string, unknown>;
};
export type Person = NamedEntity<'P'> & {
kind: 'person';
email: string | null;
};
export type Company = NamedEntity<'C'> & {
kind: 'company';
domain: string | null;
foundedAt: string | null;
};
export type Entity = Person | Company;
export type EntityId = Entity['id'];
export type Deal = {
id: DealId;
kind: 'deal';
title: string;
amount: number;
ownerId: PersonId;
contactIds: PersonId[];
stage: 'new' | 'won' | 'lost';
createdAt: string;
updatedAt: string;
};
NamedEntity сокращает повторение, но не превращается в универсального родителя. У сделки title, а не name; заметке имя тоже не нужно. Наследование ради одинаковых id и временных меток спрятало бы различия предметной области.
Entity это объединение полных вариантов. Поле kind связывает остальные поля:
function contactHint(entity: Entity): string {
if (entity.kind === 'person') {
return entity.email ?? 'Почта не указана';
}
return entity.domain ?? 'Домен не указан';
}
В первой ветке доступен email, во второй domain. Так работает размеченное объединение из урока о системе типов.
В записи встречаются оба способа объявления: interface Person extends Base и type Person = Base & .... Оба умеют выразить общие поля, но не тождественны во всём: конфликтующее свойство при extends вызывает ошибку объявления, пересечение может получить never; у интерфейсов есть слияние деклараций. Здесь нужны объединения и явные варианты, поэтому используем type. Выбирай по задаче, не по недоказанному обещанию ускорить компилятор.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.