DTO pattern
The @dto decorator turns a plain class into a reactive, cloneable, JSON-serialisable shape. It is the canonical way to model both request payloads and response bodies in this package.
Anatomy of a DTO
A DTO uses private fields with a # prefix and exposes them through getter/setter pairs. The @dto decorator wires reactivity, cloning and serialisation onto the prototype.
import { dto } from '@basmilius/http-client';
import type { DateTime } from 'luxon';
import type { PictureDto } from './PictureDto';
@dto
export class EventDto {
get id(): string {
return this.#id;
}
set id(value: string) {
this.#id = value;
}
get startsOn(): DateTime {
return this.#startsOn;
}
set startsOn(value: DateTime) {
this.#startsOn = value;
}
get headerFile(): PictureDto | null {
return this.#headerFile;
}
set headerFile(value: PictureDto | null) {
this.#headerFile = value;
}
#id: string;
#startsOn: DateTime;
#headerFile: PictureDto | null;
constructor(id: string, startsOn: DateTime, headerFile: PictureDto | null) {
this.#id = id;
this.#startsOn = startsOn;
this.#headerFile = headerFile;
}
}Behind the scenes the decorator:
- Records every property descriptor on the prototype so
clone()andfill()know which keys to copy. - Wraps the class in a Proxy whose
constructtrap returns acustomRefthat proxies the real instance — every read tracks, every write triggers. - Registers the class in a global map keyed by
clazz.namesodeserializecan re-hydrate instances by name. - Adds
clone(),fill()andtoJSON()to the prototype.
Why getter/setter pairs
DTOs use private fields with explicit getter/setter pairs because:
- The fields stay encapsulated — they cannot be reassigned directly from the outside, only through the setter that runs through the reactive proxy.
- The accessors live on the prototype, which means
getPrototypeChainpicks them up and the decorator can iterate every property descriptor to driveclone()/fill()/toJSON(). - Constructor arguments are the canonical "shape" of the DTO.
clone()re-runs the constructor with the original args, so private fields are filled even when the consumer never calls a setter.
WARNING
Avoid the public name = '' shorthand. Bare class fields skip the property descriptor on the prototype and the DTO machinery has nothing to wire reactivity to.
Reactivity
@dto instances react with customRef — exactly like a Vue ref but addressable as a regular object. They work inside computed, watch and templates without manual .value.
import { watch } from 'vue';
const event = new EventDto('event-1', startsOn, null);
watch(() => event.id, (next) => {
console.log('event id changed', next);
});
event.id = 'event-2';Cloning
Every DTO ships a clone() method. The clone re-runs the constructor with the original arguments and copies every settable property.
const original = new UserDto('user-1', 'a@example.com', 'Alice');
const draft = original.clone();
draft.fullName = 'Alice II';
console.log(original.fullName); // 'Alice'
console.log(draft.fullName); // 'Alice II'Use cloneDto when you have a value of unknown shape — it asserts the value is a DTO before calling clone().
Filling from a payload
fill() copies values from a record onto the DTO using its descriptor map. Nested DTOs are filled recursively.
const user = new UserDto('user-1', 'a@example.com', 'Alice');
user.fill({email: 'b@example.com', fullName: 'Alice II'});fill() ignores unknown keys and properties without a setter, so it is safe to feed it raw JSON from an API. In practice, prefer routing JSON through a typed adapter — fill() is most useful for in-memory updates from form state.
Serialisation
toJSON() produces a plain object using the descriptor map. Combined with serialize and deserialize you can transport DTOs through localStorage, history state or postMessage boundaries:
import { serialize, deserialize } from '@basmilius/http-client';
const json = serialize(user);
const restored = deserialize(json) as UserDto;serialize retains nested DTOs, Luxon DateTime values, arrays and plain objects. deserialize rebuilds DTO instances by their class name from the global DTO class map, so the originating class must be imported (and therefore registered) in the consuming runtime.
Dirty tracking
Forms typically need to know whether something changed. The @dto machinery tracks a per-instance DIRTY flag that flips on the first write and propagates up through PARENT references.
import {
isDtoClean,
isDtoDirty,
markDtoClean,
executeIfDtoDirtyAndMarkClean
} from '@basmilius/http-client';
const user = new UserDto('user-1', 'a@example.com', 'Alice');
isDtoDirty(user); // false
user.fullName = 'Alice II';
isDtoDirty(user); // true
await executeIfDtoDirtyAndMarkClean(user, async (dirty) => {
await userService.update(dirty);
});
isDtoClean(user); // trueAdapter integration
Adapters are the recommended boundary between raw JSON and DTOs. They keep mapping logic out of services and away from the DTO constructor.
import { adapter, type ForeignData } from '@basmilius/http-client';
import { UserDto } from '../dto/UserDto';
@adapter
export class UserAdapter {
static parseUser(data: ForeignData): UserDto {
return new UserDto(
data.id,
data.email,
data.full_name
);
}
}A common pattern is to wrap nullable nested adapters in an optional helper:
// util/optional.ts
export default function <T, U>(value: U, adapterFn: (value: U) => T): T {
if (value === undefined || value === null) {
return null as T;
}
return adapterFn(value);
}@adapter
export class UserAdapter {
static parseUser(data: ForeignData): UserDto {
return new UserDto(
data.id,
data.email,
data.full_name,
optional(data.picture, PictureAdapter.parsePicture)
);
}
}optional and friends are not part of @basmilius/http-client — they are conventions consumers tend to copy alongside the adapter pattern.
Limitations
- A
@dtoclass cannot extend another@dtoclass. The decorator throws an error during validation when a parent class also has aNAMEsymbol on its prototype. - Properties without a getter/setter pair are invisible to
clone(),fill()andtoJSON(). - Avoid using the decorator directly inside
<script setup>. Define the class in a dedicated.tsfile and import it.
Related helpers
assertDto— narrowunknownto a DTO instance.isDto— type guard variant.cloneDto— clone with built-in assertion.
Vue integration
@basmilius/common provides composables that build directly on top of DTOs:
useDtoForm— drives a clone-based form lifecycle.useService— request scaffolding around aBaseServicemethod.