ActiveModel
Proxy-based модель с контролем целостности данных (fillable, hidden, protected, readonly) и типов в рантайме — для DTO из внешних источников.
Базовые классы на TS для упрощения работы со структурами данных — реактивные поля, контроль целостности, декларативная валидация.
Как реализовать модель данных, в которой каждое свойство можно перехватывать при чтении, записи и удалении, — без ручного написания геттеров/сеттеров для каждого поля? ActiveModel оборачивает инстанс в Proxy и перехватывает get/set/deleteProperty/has/ownKeys. Поверх этого декоратор @ActiveField() декларативно описывает поведение конкретного поля — не нужно писать классы-обёртки или ручные Object.defineProperty:
import { ActiveModel, ActiveField } from '@alt-point/active-models'
class Product extends ActiveModel {
@ActiveField() name: string = ''
// getter вычисляет значение на лету, setter приводит вход к нужному виду
@ActiveField({
setter: (model, prop, value) => Math.round(Number(value) * 100) / 100,
getter: (model, prop, target) => `$${target[prop].toFixed(2)}`
})
price: number = 0
}
const product = Product.create({ name: 'Mouse', price: 19.999 })
product.price // '$20.00' — записали 19.999, а getter отдал уже готовую строку для отображенияПодробнее: Пример с decorators, Справочник опций @ActiveField().
Как защититься от того, что внешние данные (например, ответ API) случайно перезапишут защищённое поле, удалят обязательный атрибут или добавят в модель лишние ключи, которых там быть не должно? Опции fillable, readonly, protected и hidden дают точечный контроль над каждым полем по отдельности:
class User extends ActiveModel {
// задаётся один раз при создании (фабрикой или конструктором), дальше - только чтение
@ActiveField({ readonly: true, attribute: () => crypto.randomUUID() })
id!: string
// нельзя удалить свойство через delete user.role
@ActiveField({ protected: true })
role: string = 'guest'
// не попадёт в Object.keys()/JSON.stringify(), но доступно напрямую
@ActiveField({ hidden: true })
internalNotes: string = ''
}
const user = User.create({ id: 'ignored-from-outside', role: 'admin' })
user.id // сгенерированный uuid, а не 'ignored-from-outside'
user.id = 'x' // выбросит исключение - поле уже установлено
delete user.role // выбросит исключение - поле защищено от удаления
JSON.stringify(user) // internalNotes отсутствует в результатеПодробнее: Справочник опций @ActiveField(), включая таблицу сравнения readonly и fillable: false.
TypeScript проверяет типы только на этапе компиляции, а данные из внешних источников (API, localStorage, WebSocket) приходят в рантайме и никакими типами не гарантированы. validator выполняется при каждой попытке установить значение и бросает исключение, если данные не подходят под ожидаемый формат; factory автоматически оборачивает вложенные структуры в собственные ActiveModel, сохраняя валидацию на любом уровне вложенности:
enum OrderStatus { New = 'new', Paid = 'paid', Shipped = 'shipped' }
class Address extends ActiveModel {
@ActiveField() city: string = ''
}
class Order extends ActiveModel {
@ActiveField({
validator: (model, prop, value) => {
if (!Object.values(OrderStatus).includes(value)) {
throw new Error(`Invalid status: ${value}`)
}
}
})
status: OrderStatus = OrderStatus.New
// вложенный объект автоматически станет экземпляром Address со своей валидацией
@ActiveField({ factory: Address })
shippingAddress?: Address
}
const order = Order.create({ status: 'paid', shippingAddress: { city: 'Berlin' } })
order.shippingAddress instanceof Address // true
order.status = 'not-a-real-status' // выбросит Error: Invalid status: not-a-real-statusПодробнее: Продвинутые возможности — валидаторы и factory.
Как узнать, что конкретное поле модели изменилось, было обнулено или удалено, — не оборачивая каждое присваивание в собственный код? События beforeSetValue, afterSetValue, nulling, beforeDeletingAttribute подписываются прямо в декораторе через on/once, а touched/created — на уровне всего инстанса через model.emitter:
import { EventType } from '@alt-point/active-models'
class Invoice extends ActiveModel {
@ActiveField({
on: {
afterSetValue: ({ prop, value, oldValue }) => {
console.log(`${prop}: ${oldValue} → ${value}`)
},
nulling: ({ prop }) => console.log(`${prop} was explicitly cleared`)
}
})
total: number | null = 0
static beforeFill (model: any) {
model.emitter.on(EventType.created, () => console.log('invoice fully built'))
model.emitter.on(EventType.touched, () => console.log('invoice changed'))
}
}
const invoice = Invoice.create({ total: 100 })
// invoice fully built
invoice.total = 250
// total: 100 → 250
// invoice changed
invoice.total = null
// total: 250 → null
// invoice changed
// total was explicitly clearedПодробнее: Жизненный цикл модели — все события от создания до удаления, с диаграммой.
yarn add @alt-point/active-modelsnpm install --save @alt-point/active-modelsbun add @alt-point/active-models@ActiveField() — каждая опция по отдельности, с примерамиnew/create/fill, сериализация, mapTo()node:httpEnglish version: /en/