@fluojs/serialization
v2.0.0
Published
Class-based response serialization and output shaping interceptors for Fluo.
Maintainers
Readme
@fluojs/serialization
Node.js support is >=24.0.0 <27. See Node.js support and migration before upgrading.
Class-based response serialization and output shaping for fluo with decorator-aware recursive object walking.
Table of Contents
- Installation
- When to Use
- Decorator Metadata Preload
- Quick Start
- Common Patterns
- Public API Overview
- Related Packages
- Example Sources
Installation
pnpm add @fluojs/serializationWhen to Use
- when you need output DTOs to expose only a controlled subset of fields
- when sensitive values such as password hashes or internal identifiers must never leave the response boundary
- when response data needs lightweight synchronous transforms during serialization
- when you want an HTTP interceptor to apply the same serialization rules automatically
Decorator Metadata Preload
@fluojs/serialization does not install Symbol.metadata as an import side effect. When your target runtime does not provide it natively, install it before importing any module that evaluates classes decorated with @Expose(), @Exclude(), or @Transform():
// preload.ts — configure this as the application entrypoint
import { ensureMetadataSymbol } from '@fluojs/core';
ensureMetadataSymbol();
await import('./bootstrap.js');Quick Start
import { Exclude, Expose, Transform, serialize } from '@fluojs/serialization';
class UserEntity {
@Expose()
id = '';
@Expose()
@Transform((value) => String(value).toUpperCase())
username = '';
@Exclude()
passwordHash = '';
}
const user = Object.assign(new UserEntity(), {
id: '1',
username: 'fluo',
passwordHash: 'secret',
});
console.log(serialize(user));
// { id: '1', username: 'FLUO' }Common Patterns
Expose-only output DTOs
import { Expose } from '@fluojs/serialization';
@Expose({ excludeExtraneous: true })
class SecureDto {
@Expose()
publicData = 'visible';
internalData = 'hidden';
}ExposeClassOptions is the exported class-level options type accepted by Expose(...). Use excludeExtraneous: true when a DTO should emit only fields with field-level @Expose() metadata.
Value transforms
import { Transform } from '@fluojs/serialization';
class ProductDto {
@Transform((price) => `$${Number(price).toFixed(2)}`)
price = 0;
}When the same field is decorated in a base class and a derived class, transforms run in declaration order from base to derived.
TransformFunction is a synchronous (value: unknown) => unknown callback: it receives only the current field value, so use it for value-only transforms rather than async work or access to the DTO, property metadata, or serialization context.
HTTP response shaping with an interceptor
import { Controller, Get, type RequestContext, UseInterceptors } from '@fluojs/http';
import { SerializerInterceptor } from '@fluojs/serialization';
@Controller('/users')
@UseInterceptors(SerializerInterceptor)
class UsersController {
@Get('/')
findAll() {
return [new UserEntity()];
}
@Get('/export.csv')
async exportCsv(_input: undefined, context: RequestContext) {
context.response.setHeader('Content-Type', 'text/csv; charset=utf-8');
await context.response.send('id,username\n1,fluo');
}
}The two routes use different response owners:
- Framework-managed response:
findAll()returns whileRequestContext.responseis still uncommitted.SerializerInterceptorserializes the returned DTOs, then the runtime response writer commits the result. - Handler-owned response:
exportCsv()writes the final payload throughRequestContext.response.send(...). Oncesend(...),redirect(...), or a manual streaming helper commits the response,SerializerInterceptorbypassesserialize(...)and returns the value it received fromnext.handle()unchanged. This guarantee is specific toSerializerInterceptor; other interceptors may still transform the chain result. Independently, the dispatcher sees the committed response and skips a second success-response write.
Treat a directly written payload as final: apply any required field filtering or encoding before the commit. Serialization cannot post-process a response after handler/runtime response ownership has been committed.
Cycle-safe serialization
The serializer cuts active cyclic references safely instead of recursing forever, so complex object graphs can still be turned into plain response-shaped objects without unbounded recursion. The same reference tracker is shared across class instances, plain objects, arrays, and mixed object-array graphs. Self-referential arrays and object-array cycles are cut at the active back edge, while completed shared references are reused in the serialized graph rather than dropped: if two sibling fields, or an object field and an array entry, point at the same source object, both serialized fields point at the same serialized object. Only a value that is encountered again while it is already being serialized is cut to undefined.
Inherited decorator contracts
Serialization metadata declared on a base class is inherited by derived DTOs. @Expose(), @Exclude(), and @Transform() rules applied to shared base fields still take effect when you serialize subclass instances.
Derived decorators own their metadata updates, so overriding a field or class option never changes the later serialization of the base DTO or a sibling DTO.
Class-level excludeExtraneous also follows normal inheritance. A derived class with @Expose() and no options keeps the nearest inherited setting, so an expose-only base DTO remains expose-only in subclasses. Use @Expose({ excludeExtraneous: false }) on the derived class only when you intentionally want to re-enable ordinary enumerable fields while still honoring inherited field-level @Exclude() metadata.
Undecorated class instances are still traversed recursively, so decorated nested descendants are respected even when the parent object has no serialization metadata.
Plain-object safety
serialize() treats plain objects and null-prototype records as data containers, not decorated class instances. Enumerable symbol keys are serialized, own __proto__, constructor, and prototype keys are treated as data rather than prototype mutations, and objects with custom or unsafe constructor fields are walked safely without throwing.
Non-JSON leaf values
serialize() applies decorator metadata and recursively walks arrays/plain objects, but it does not coerce every leaf into strict JSON types. Opaque built-ins such as Date, Map, Set, URL, URLSearchParams, RegExp, Error, ArrayBuffer, typed arrays, WeakMap, WeakSet, and Promise pass through unchanged instead of being flattened as DTO-like class instances. Values such as bigint, functions, and symbols can also pass through unchanged unless you normalize them with @Transform(...) or before writing the final HTTP response.
Public API Overview
- Decorators:
Expose,Exclude,Transform - Engine:
serialize(value)recursively walks class instances, arrays, plain objects, and mixed graphs while preserving opaque built-ins and non-JSON leaf values unless you transform them - HTTP integration:
SerializerInterceptorserializes uncommitted handler results; after the response is committed, it returns the value it received fromnext.handle()unchanged, although other interceptors may still transform the chain result - Types:
ExposeClassOptionsis exported from the root entrypoint for class-levelExpose(...)options, andTransformFunctionis exported for callbacks passed toTransform(...)
Expose can be applied to classes and fields. Exclude and Transform apply to fields.
Related Packages
@fluojs/http: appliesSerializerInterceptorto HTTP handlers@fluojs/validation: handles input-side DTO materialization and validation
Example Sources
packages/serialization/src/serialize.test.tspackages/serialization/src/serializer-interceptor.test.ts
