fatline

Custom Types

Register your own types for automatic serialization

fatline handles Date, Set, Map, BigInt, and other built-in types automatically. For your own types—Decimal, Money, domain objects—you register them once and they serialize everywhere.

Basic Registration

import { registerType } from 'fatline'
import Decimal from 'decimal.js'

registerType({
  name: 'Decimal',
  test: (v): v is Decimal => v instanceof Decimal,
  encode: (v) => v.toString(),
  decode: (v) => new Decimal(v),
})

That's it. Now every Decimal in your data serializes and deserializes automatically:

serialize({ price: new Decimal('19.99') })
// → {"price":["~",47821,"19.99"]}

deserialize(json)
// → { price: Decimal { ... } }

How IDs Work

Type IDs 0-20 are reserved for built-in types. Custom types get IDs 21+.

Auto-assigned (recommended): fatline generates a stable ID from the type name using FNV-1a hash:

import { registerType, getTypeId } from 'fatline'

registerType({
  name: 'Decimal',  // ID derived from 'Decimal'
  test: (v): v is Decimal => v instanceof Decimal,
  encode: (v) => v.toString(),
  decode: (v) => new Decimal(v),
})

getTypeId('Decimal')  // 47821 (stable, deterministic)

Manual ID: For compatibility with existing serialized data:

registerType({
  id: 25,           // Explicit ID
  name: 'Money',
  test: (v): v is Money => v instanceof Money,
  encode: (v) => v.cents,
  decode: (v) => new Money(v),
})

Common Patterns

Decimal.js / Prisma Decimal

import Decimal from 'decimal.js'

registerType({
  name: 'Decimal',
  test: (v): v is Decimal => v instanceof Decimal,
  encode: (v) => v.toString(),
  decode: (v) => new Decimal(v),
})

Money / Currency

class Money {
  constructor(public cents: number, public currency: string) {}
}

registerType({
  name: 'Money',
  test: (v): v is Money => v instanceof Money,
  encode: (v) => ({ cents: v.cents, currency: v.currency }),
  decode: (v) => new Money(v.cents, v.currency),
})

Temporal (Stage 3 Proposal)

registerType({
  name: 'Temporal.Instant',
  test: (v): v is Temporal.Instant => v instanceof Temporal.Instant,
  encode: (v) => v.epochNanoseconds.toString(),
  decode: (v) => Temporal.Instant.fromEpochNanoseconds(BigInt(v)),
})

Luxon DateTime

import { DateTime } from 'luxon'

registerType({
  name: 'DateTime',
  test: (v): v is DateTime => DateTime.isDateTime(v),
  encode: (v) => v.toISO(),
  decode: (v) => DateTime.fromISO(v),
})

Branded Types / Opaque Types

For TypeScript branded types, you typically don't need custom registration since they're just primitives at runtime:

type UserId = string & { readonly __brand: 'UserId' }

// No registration needed—it's just a string at runtime
serialize({ id: 'user_123' as UserId })

Registration Timing

Register types before any serialize/deserialize calls:

// ✓ Good: Register at app startup
import { registerType } from 'fatline'
import './types/decimal'  // Registers Decimal type

// ✗ Bad: Lazy registration
function getPrice() {
  registerType({ ... })  // Too late if data was already serialized elsewhere
}

For frameworks, register in your entry point or a shared module that's imported first.

Testing Custom Types

import { serialize, deserialize, registerType } from 'fatline'

registerType({
  name: 'Money',
  test: (v): v is Money => v instanceof Money,
  encode: (v) => v.cents,
  decode: (v) => new Money(v),
})

test('Money round-trips', () => {
  const original = new Money(1999)
  const json = serialize({ price: original })
  const restored = deserialize(json)

  expect(restored.price).toBeInstanceOf(Money)
  expect(restored.price.cents).toBe(1999)
})

Next Steps

On this page