Skip to content

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:

SymbolApplied toPurpose
Symbol.for('@nhtio/encoder:toEncoded')Instance methodSerialize the instance into an Encodable snapshot
Symbol.for('@nhtio/encoder:fromEncoded')Static methodReconstruct 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)

typescript
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:

typescript
// 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:

typescript
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:

typescript
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               // 7

Nesting

Custom classes can be nested inside plain objects, arrays, Maps, Sets, or other custom classes. The encoder handles all of these automatically:

typescript
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 // true

The 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.

typescript
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.

typescript
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:

typescript
// ❌ Will throw — no name
registerClass(class { ... })

// ✅ Fine
class Point { ... }
registerClass(Point)

// ✅ Also fine — explicit name
const Point = class Point { ... }
registerClass(Point)

Error handling

ErrorWhen thrown
E_ENCODING_FAILED[ENCODE_METHOD]() throws an error
E_CIRCULAR_REFERENCEThe object graph contains a cycle
E_UNDECODABLE_VALUEThe class was not registered before decode() was called
typescript
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:

typescript
import { isCustomEncodable } from '@nhtio/encoder/type_guards'

isCustomEncodable(new Point(1, 2)) // true
isCustomEncodable({ x: 1, y: 2 }) // false — plain object, no symbols

TypeScript interface

For TypeScript users, the CustomEncodable interface describes the shape the encoder expects:

typescript
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)
  }
}