Custom Classes
The encoder supports encoding and decoding your own classes without making @nhtio/encoder a dependency of the class itself.
How it works
The encoder uses two well-known Symbol.for() keys to discover the encode and decode logic on a class:
| Symbol | Applied to | Purpose |
|---|---|---|
Symbol.for('@nhtio/encoder:toEncoded') | Instance method | Serialize the instance into an Encodable snapshot |
Symbol.for('@nhtio/encoder:fromEncoded') | Static method | Reconstruct an instance from that snapshot |
Because Symbol.for() uses a global registry keyed by string, any code can recreate the same symbol from just the string — no import from @nhtio/encoder required in the class file.
Opting in
Option A — import the symbols (class is already coupled to the library)
import { ENCODE_METHOD, DECODE_METHOD, registerClass } from '@nhtio/encoder'
class Point {
constructor(public x: number, public y: number) {}
[ENCODE_METHOD]() {
return { x: this.x, y: this.y }
}
static [DECODE_METHOD](data: { x: number; y: number }) {
return new Point(data.x, data.y)
}
}
registerClass(Point)Option B — recreate the symbols (zero dependency on this library)
Use this when the class lives in a package that should not depend on @nhtio/encoder:
// In your own package — no @nhtio/encoder import needed
const ENCODE = Symbol.for('@nhtio/encoder:toEncoded')
const DECODE = Symbol.for('@nhtio/encoder:fromEncoded')
class Point {
constructor(public x: number, public y: number) {}
[ENCODE]() {
return { x: this.x, y: this.y }
}
static [DECODE](data: { x: number; y: number }) {
return new Point(data.x, data.y)
}
}
export { Point }Then at your application boundary (where @nhtio/encoder is a dependency), register the class for decoding:
import { registerClass } from '@nhtio/encoder'
import { Point } from './point'
registerClass(Point)Encoding and decoding
Once a class is registered, encode() and decode() work as usual:
import { encode, decode } from '@nhtio/encoder'
const original = new Point(3, 7)
const encoded = encode(original)
const decoded = decode<Point>(encoded)
decoded instanceof Point // true
decoded.x // 3
decoded.y // 7Nesting
Custom classes can be nested inside plain objects, arrays, Maps, Sets, or other custom classes. The encoder handles all of these automatically:
class Color {
constructor(public r: number, public g: number, public b: number) {}
[ENCODE_METHOD]() { return { r: this.r, g: this.g, b: this.b } }
static [DECODE_METHOD](d: { r: number; g: number; b: number }) {
return new Color(d.r, d.g, d.b)
}
}
registerClass(Color)
// Nested inside a plain object
const theme = { background: new Color(15, 15, 15), text: new Color(240, 240, 240) }
const decoded = decode<typeof theme>(encode(theme))
decoded.background instanceof Color // true
// Nested inside an array
const palette = [new Color(255, 0, 0), new Color(0, 255, 0)]
const decodedPalette = decode<Color[]>(encode(palette))
decodedPalette[0] instanceof Color // true
// Nested inside another custom class
class Theme {
constructor(public background: Color, public text: Color) {}
[ENCODE_METHOD]() { return { background: this.background, text: this.text } }
static [DECODE_METHOD](d: { background: Color; text: Color }) {
return new Theme(d.background, d.text)
}
}
registerClass(Theme)
const decoded2 = decode<Theme>(encode(new Theme(new Color(15, 15, 15), new Color(240, 240, 240))))
decoded2 instanceof Theme // true
decoded2.background instanceof Color // trueThe snapshot
The value returned by [ENCODE_METHOD]() can be any Encodable — primitives, arrays, Maps, other custom classes, Luxon dates, etc. The full power of the encoder is available within your snapshot.
import { DateTime } from 'luxon'
class Event {
constructor(
public title: string,
public start: DateTime,
public tags: Set<string>,
) {}
[ENCODE_METHOD]() {
return { title: this.title, start: this.start, tags: this.tags }
}
static [DECODE_METHOD](d: { title: string; start: DateTime; tags: Set<string> }) {
return new Event(d.title, d.start, d.tags)
}
}
registerClass(Event)Registering classes
registerClass() must be called before the first decode() call for that class. Encoding never requires registration — only decoding does, because the decoder needs the constructor reference.
import { registerClass } from '@nhtio/encoder'
import { Point } from './point'
import { Color } from './color'
// Register at application startup
registerClass(Point)
registerClass(Color)Anonymous classes
Anonymous classes cannot be registered because they have no stable name. Give your class an explicit name:
// ❌ Will throw — no name
registerClass(class { ... })
// ✅ Fine
class Point { ... }
registerClass(Point)
// ✅ Also fine — explicit name
const Point = class Point { ... }
registerClass(Point)Error handling
| Error | When thrown |
|---|---|
E_ENCODING_FAILED | [ENCODE_METHOD]() throws an error |
E_CIRCULAR_REFERENCE | The object graph contains a cycle |
E_UNDECODABLE_VALUE | The class was not registered before decode() was called |
import { decode } from '@nhtio/encoder'
import { E_UNDECODABLE_VALUE } from '@nhtio/encoder/exceptions'
try {
decode(encodedPoint)
} catch (e) {
if (e instanceof E_UNDECODABLE_VALUE) {
// Most likely registerClass(Point) was not called before decode()
}
}Type guard
Use isCustomEncodable() to check whether a value implements the interface:
import { isCustomEncodable } from '@nhtio/encoder/type_guards'
isCustomEncodable(new Point(1, 2)) // true
isCustomEncodable({ x: 1, y: 2 }) // false — plain object, no symbolsTypeScript interface
For TypeScript users, the CustomEncodable interface describes the shape the encoder expects:
import type { CustomEncodable, Encodable } from '@nhtio/encoder'
import { ENCODE_METHOD, DECODE_METHOD } from '@nhtio/encoder'
class Point implements CustomEncodable {
constructor(public x: number, public y: number) {}
[ENCODE_METHOD](): Encodable {
return { x: this.x, y: this.y }
}
static [DECODE_METHOD](data: Encodable): Point {
const { x, y } = data as { x: number; y: number }
return new Point(x, y)
}
}