open atlas
↑ К треку
Паттерны и качество кода CP · 08 · 02

Одержимость примитивами и группы данных

Деньги, email или диапазон дат как голая строка или число разбрасывают валидацию и правила по коду. Поля, всегда путешествующие вместе, — сигнал отсутствующей концепции. Лекарство — объект-значение, применённый там, где есть инвариант, а не как рефлекторная церемония.

CP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

number по имени amount — это ложь умолчанием. Он говорит тебе, что значение числовое; он не говорит ничего о том, доллары это или центы, может ли оно быть отрицательным, какую валюту несёт и какое правило округления применять, когда ты делишь его на три. Поэтому каждое место, касающееся денег, заново выводит эти ответы — и они расходятся. Один модуль хранит центы, другой — доллары, третий делит на 100 «на всякий случай», и возврат внезапно ошибается на два порядка.

Этот баг — не опечатка. Это предсказуемый результат представления доменной концепции — денег — голым примитивом. Та же болезнь всплывает, когда поля отказываются ходить поодиночке: lat и lng, startDate и endDate, всегда передаются рядом, всегда валидируются по отдельности, всегда в одной перестановке параметров от дефекта. Это два самых частых раздувателя, которые ты встретишь, и у них одно лекарство.

Цель

После этого урока ты можешь распознавать одержимость примитивами (доменную концепцию, смоделированную как string/number/boolean) и группы данных (одну и ту же группу значений, всегда путешествующих вместе); объяснить, почему обе разбрасывают валидацию и правила, пока объект-значение не стянет их в одно место; ввести объект-значение, который централизует инвариант и поведение; и — senior-половина — судить, когда обёртка над примитивом окупается, а когда она церемония, не покупающая ничего.

1

Одержимость примитивами — это использование языкового примитива вместо доменной концепции, что заставляет каждого вызывающего знать правила концепции. У денежного значения есть инварианты (валюта, масштаб, неотрицательность для цены) и поведение (складывать только одну валюту, округлять при делении). Смоделированное как number, ничего из этого не путешествует со значением, поэтому каждое место вызова заново реализует это — несогласованно.

// Money as a bare number: the rules live nowhere, so they live everywhere.
function priceWithTax(amount: number): number {
  return amount + amount * 0.2; // dollars? cents? can amount be -5? nobody knows
}

const refund = order.total / 3;   // float division → 33.333333, a fraction of a cent
const eur = usdAmount;            // type system is fine with mixing currencies

Каждая из этих строк локально разумна и глобально неверна. number не несёт ни масштаба, ни валюты, ни политики округления — поэтому политика дублируется, а дублированные правила дрейфуют.

2

Объект-значение централизует валидацию и поведение, которые примитив разбросал, так что у правила ровно один дом. Оберни концепцию в маленький неизменяемый тип, владеющий своими инвариантами и операциями. Теперь есть одно место, где правило может быть неверным, и одно место, чтобы его поправить.

class Money {
  private constructor(readonly cents: number, readonly currency: string) {}

  static of(cents: number, currency: string): Money {
    if (!Number.isInteger(cents)) throw new Error("Money is whole cents");
    if (cents < 0) throw new Error("Money cannot be negative");
    return new Money(cents, currency);
  }

  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error("currency mismatch");
    return new Money(this.cents + other.cents, this.currency);
  }
}

Конструирование — единственная дверь, поэтому невалидный Money не может существовать. Смешивание валют теперь — выброшенная ошибка вместо молчаливого usd + eur. Валидация, которую копипастили по priceWithTax, refund и convert, схлопывается в тип — тот самый рычаг усиления изменения из юнита 00, применённый к одному значению.

3

Группа данных — это одна и та же группа значений, всегда путешествующих вместе; повторяющаяся совместность — это рантайм, сообщающий тебе, что концепции не хватает. Когда lat и lng появляются в одном списке параметров везде, валидируются по отдельности везде, эта пара и есть концепция — Coordinate, — которую ты просто ещё не назвал.

// Before: lat & lng are a clump — three params that are really one thing,
// plus a parameter-order bug waiting to happen.
function distance(lat1: number, lng1: number, lat2: number, lng2: number): number { /* … */ }
distance(lng1, lat1, lng2, lat2); // swapped, compiles, ships, wrong by a continent

// After: name the concept the clump was pointing at.
class Coordinate {
  constructor(readonly lat: number, readonly lng: number) {
    if (Math.abs(lat) > 90 || Math.abs(lng) > 180) throw new Error("out of range");
  }
  distanceTo(other: Coordinate): number { /* … */ }
}

Группа не просто укоротила сигнатуру. Она дала проверке диапазона один дом, убила целый класс багов с перестановкой порядка и дала поведению (distanceTo) естественное место для жизни вместо того, чтобы плавать свободной функцией с четырьмя разболтанными аргументами.

4

Режим отказа — обратный рефлекс: оборачивать каждый примитив в класс, даже когда он не несёт ни инварианта, ни поведения, — церемония без отдачи. Объект-значение окупается, когда есть правило для централизации: валидация, арифметика, которая должна оставаться согласованной, единица измерения, которую нельзя перепутать. Примитив, у которого нет ничего из этого, не выигрывает от обёртки ничего, кроме косвенности.

// Payoff: EmailAddress has a real invariant (must be a valid address) reused everywhere.
class EmailAddress { /* validates once, every caller trusts it */ }

// Ceremony: a wrapper with zero invariant and zero behaviour.
class FirstName {
  constructor(readonly value: string) {} // any string is a valid first name
}
// Now every read is `name.value` and you've added a type that buys nothing.

Senior-тест — не «это примитив?», а «есть ли у этой концепции правило или поведение, которое сейчас дублируется или не обеспечивается?». Если да — объект-значение его централизует. Если нет — оставь примитив: алиас типа (type UserId = string) часто схватывает намерение при нулевой церемонии. Переобёртывание — само по себе запах; оно стоит времени чтения вместо корректности.

Разбор примера

Баг «деньги-как-число», исправленный типом Money. Оформление заказа хранит цены в центах, но хелпер скидки был написан в предположении долларов:

// Before — the unit lives in the programmer's head, not the value.
function applyDiscount(priceCents: number, percentOff: number): number {
  const discount = priceCents * (percentOff / 100);
  return priceCents - discount;          // ok so far
}

// elsewhere, a teammate "helpfully" converts, assuming dollars:
const display = applyDiscount(1999, 10) / 100;   // 17.991  → renders "$17.99"
const refundCents = applyDiscount(1999, 10);     // 1799.1  → a fractional cent
chargeCard(refundCents);                         // gateway rejects / rounds unpredictably

1799.1 цента — непредставимая сумма списания, и ничто в сигнатуре её не остановило. Исправление — не «не забудь округлить», рассыпанное по каждому месту, — а сделать невалидное значение непостроимым:

// After — Money owns whole-cents and currency; division must state its rounding.
class Money {
  private constructor(readonly cents: number, readonly currency: string) {}
  static of(cents: number, currency: string): Money {
    if (!Number.isInteger(cents)) throw new Error("cents must be integer");
    return new Money(cents, currency);
  }
  percentOff(pct: number): Money {
    // explicit rounding policy, decided once, here.
    const off = Math.round(this.cents * (pct / 100));
    return new Money(this.cents - off, this.currency);
  }
}

const price = Money.of(1999, "USD");
const discounted = price.percentOff(10);   // Money { cents: 1799, "USD" } — always integer cents
chargeCard(discounted);                     // gateway always gets a valid amount

Класс багов — дробные центы, путаница долларов и центов, неокруглённое деление — не может повториться, потому что единственный способ сделать Money — это дверь, которая обеспечивает инвариант, а решение об округлении теперь имеет единственный именованный дом вместо того, чтобы переугадываться на каждом месте вызова.

Почему это работает

Почему именование повторяющейся группы важно сверх укороченной сигнатуры? Потому что группа — это конкретность по позиции (connascence of position), превращённая в генератор дефектов: четыре разболтанных аргумента совместимых типов позволяют distance(lng, lat, …) скомпилироваться и уехать в прод с ошибкой размером с континент. Сборка их в Coordinate превращает позиционный контракт (вызывающие должны помнить порядок) в именованный (вызывающие передают Coordinate) и даёт валидации и поведению distanceTo место для жизни. Запах — «они всегда появляются вместе» — всё это время был обратной связью дизайна: рантайм описывал концепцию, которую твоя система типов ещё не признала.

Частая ошибка

Перекоррекция — относиться к «никаких голых примитивов» как к закону. Обёртывание FirstName, MiddleName, Title — каждый { value: string } без инварианта — добавляет тип, разворачивание (.value) на каждом чтении и трение сериализации, предотвращая ровно ноль багов. Объект-значение заслуживает своё место, централизуя правило; без правила централизовать нечего. Когда ты хочешь лишь не путать два одинаково типизированных id, тянись сначала за самым дешёвым инструментом, выражающим намерение, — алиасом type UserId = string или брендированным типом — и эскалируй до полноценного объекта-значения только тогда, когда есть подлинная валидация или поведение во владение.

Проверь себя
Викторина

По всей кодовой базе цены передаются как голые `number`, а хелпер скидки выдал списание с дробным центом, которое платёжный шлюз отверг. Коллега предлагает обернуть каждое примитивное поле в системе — включая `firstName` и `title` — в собственный класс. Каков senior-выбор?

Итог

Одержимость примитивами моделирует доменную концепцию — деньги, email, диапазон дат — как голую string или number, что заставляет каждого вызывающего заново выводить валидацию и правила концепции, пока они не дрейфуют и возврат не промахнётся на два порядка. Группы данных — это одни и те же поля, всегда путешествующие вместе (lat+lng, start+end); их повторяющаяся совместность — это рантайм, называющий концепцию, которую твои типы не признали. Лекарство для обоих — объект-значение / передача целого объекта: неизменяемый тип, чей конструктор — единственная дверь, стягивающий инвариант и поведение в один дом, так что вызывающие зависят от концепции, а не от правила. Senior-дисциплина — знать, когда не делать этого: обёртка без инварианта и без поведения — церемония без отдачи; там предпочти алиас типа, а объект-значение прибереги для концепций, которые действительно несут правило, достойное централизации.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.