Type-safe JSON ↔ Class serialization for TypeScript
yarn add @depthbomb/serde
bun add @depthbomb/serde
npm install @depthbomb/serdeLegacy TypeScript decorators use the following setting. Standard ECMAScript field decorators are also supported and do not require experimentalDecorators.
Standard decorators expose schema metadata before any instances are created, including inherited fields. Serde supplies Symbol.metadata when the runtime lacks it, as required by TypeScript decorator metadata. No constructors run during schema generation.
{
"compilerOptions": {
"experimentalDecorators": true,
"strict": true
}
}import { toJSON, serialize, deserialize, Serializable, JSONProperty } from '@depthbomb/serde';
@Serializable()
class User {
@JSONProperty({ name: 'first_name' })
firstName!: string;
@JSONProperty({ name: 'last_name' })
lastName!: string;
@JSONProperty()
age!: number;
}
// Deserialize
const user = deserialize(User, { first_name: 'Leon', last_name: 'Kennedy', age: 49 });
console.log(user.firstName); // 'Leon'
console.log(user instanceof User); // true
// Serialize
const plain = serialize(user); // { first_name: 'Leon', last_name: 'Kennedy', age: 49 }
const json = toJSON(user, 2); // pretty-printed JSON stringMarks a class as a serialization target. Required for any class used as a nested type.
@Serializable()
class MyClass { ... }Marks a property for (de)serialization. All options are optional.
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
property name | JSON key to read/write |
aliases |
string[] |
[] |
Legacy JSON keys accepted on input |
type |
Constructor | () => Constructor |
— | Nested class type (use thunk for forward refs) |
codec |
JSONCodec<T> |
— | Reusable typed value ↔ JSON conversion; mutually exclusive with type |
isArray |
boolean |
false |
Property holds T[] |
isMap |
boolean |
false |
Property holds Map<string, T> |
isSet |
boolean |
false |
Property holds Set<T> |
optional |
boolean |
true |
Skip if key absent; false = required |
nullable |
'ignore' | 'null' | 'error' |
'ignore' |
Behaviour when value is null |
defaultValue |
T | (() => T) |
— | Default when key is absent |
deserializeTransform |
(raw: unknown) => T |
identity | Post-deserialization transform |
serializeTransform |
(value: T) => unknown |
identity | Pre-serialization transform |
validate |
(value: T) => boolean | string | void |
— | Validator; false/string throws |
deserializeAsyncTransform |
(value: T) => Promise<T> |
— | Async post-deserialization transform |
serializeAsyncTransform |
(value: T) => Promise<unknown> |
— | Async serialization transform |
validateAsync |
(value: T) => Promise<boolean | string | void> |
— | Async validator |
groups |
string[] |
[] |
Named serialization projections |
sensitive |
boolean |
false |
Omit unless includeSensitive is enabled |
Enable polymorphic deserialization using a discriminator field.
@Serializable()
@JSONDiscriminator('type')
@JSONSubType('circle', Circle)
@JSONSubType('rectangle', Rectangle)
class Shape {
@JSONProperty() type!: string;
@JSONProperty() color!: string;
}
// Automatically dispatches to the right subclass:
const s = deserialize(Shape, { type: 'circle', color: 'red', radius: 5 });
console.log(s instanceof Circle); // true// Deserialization
deserialize<T>(ctor: Constructor<T>, data: Record<string, unknown> | string, path?: string, options?: IDeserializeOptions): T
deserializeArray<T>(ctor: Constructor<T>, data: Record<string, unknown>[] | string, path?: string, options?: IDeserializeOptions): T[]
deserializeAsync<T>(ctor: Constructor<T>, data: Record<string, unknown> | string, path?: string, options?: IDeserializeOptions): Promise<T>
deserializeArrayAsync<T>(...): Promise<T[]>
fromJSON<T>(ctor: Constructor<T>, json: string): T
fromJSONAsync<T>(ctor: Constructor<T>, json: string): Promise<T>
// Serialization
serialize<T extends object>(instance: T, path?: string, options?: ISerializeOptions): Record<string, unknown>
serializeArray<T extends object>(instances: T[], path?: string, options?: ISerializeOptions): Record<string, unknown>[]
serializeAsync<T extends object>(instance: T, path?: string, options?: ISerializeOptions): Promise<Record<string, unknown>>
serializeArrayAsync<T extends object>(...): Promise<Record<string, unknown>[]>
toJSON<T>(instance: T, space?: number): string
toJSONAsync<T>(instance: T, space?: number): Promise<string>
// Utilities
clone<T>(ctor: Constructor<T>, instance: T): T
patch<T>(ctor: Constructor<T>, instance: T, partial: Record<string, unknown>): T
isSerializable(ctor: Constructor): boolean
isEnum(obj: unknown): boolean
generateJSONSchema(ctor: Constructor, namingStrategy?: NamingStrategy): Record<string, unknown>When calling deserialize() or deserializeArray(), pass a fourth options argument:
interface IDeserializeOptions {
/**
* When `true`, any JSON keys not declared via `@JSONProperty` cause
* a `SerializationError`. Useful for validating untrusted input.
* Defaults to `false`.
*/
strict?: boolean;
/**
* Global key mapping strategy for JSON <-> property name conversion.
* Explicit `@JSONProperty({ name })` values always win.
*/
namingStrategy?: (propertyKey: string) => string;
unknownProperties?: 'ignore' | 'error' | 'collect';
unknownProperty?: string; // destination property for collect mode
}Example:
const user = deserialize(User, data, '$', { strict: true });interface ISerializeOptions {
/**
* Global key mapping strategy for property -> JSON key conversion.
* Explicit `@JSONProperty({ name })` values always win.
*/
namingStrategy?: (propertyKey: string) => string;
groups?: string[];
includeSensitive?: boolean;
}Aliases accept old names while serialization always emits the canonical name. Version migrations run in order; migration N upgrades version N to N + 1.
@Serializable()
@JSONVersion(2, {
migrations: {
0: data => ({ ...data, name: data.old_name }),
1: data => ({ ...data, fullName: data.name }),
},
})
class User {
@JSONProperty({ aliases: ['name'], optional: false }) fullName!: string;
}
const value = deserialize(User, payload, '$', {
unknownProperties: 'collect',
unknownProperty: 'extensions',
});Serialization emits the current $version. Configure another field with JSONVersion(..., { field: 'version' }).
const epochCodec: JSONCodec<Date, number> = {
serialize: date => date.getTime(),
deserialize: value => new Date(value),
schema: { type: 'integer' },
};
class Session {
@JSONProperty({ codec: epochCodec }) createdAt!: Date;
@JSONProperty({ groups: ['admin'], sensitive: true }) token!: string;
}
serialize(session, '$', { groups: ['admin'], includeSensitive: true });generateJSONSchema(Session) produces draft 2020-12 JSON Schema, including nested definitions, enums, aliases, groups, nullability, collections, and codec schema fragments.
In async serialization, serializeAsyncTransform receives the original non-null property value and produces its wire representation. It takes precedence over that property's synchronous transform, codec, and collection conversion. Its output is recursively normalized, including nested serializable instances. Projection filtering and null handling apply before invoking the transform; null handling also applies to its result.
You can globally format property keys into standardized JSON casing like snake_case or PascalCase without manually applying the .name attribute on every single property wrapper:
import { NamingStrategies } from '@depthbomb/serde';
@Serializable()
class User {
@JSONProperty() firstName!: string;
@JSONProperty() lastName!: string;
}
const payload = { first_name: 'John', last_name: 'Doe' };
const user = deserialize(User, payload, '$', {
namingStrategy: NamingStrategies.camelToSnake
});
console.log(user.firstName); // "John"Note: Any property that explicitly declares
@JSONProperty({ name: 'CUSTOM' })will bypass the NamingStrategy directly and preserve its intentional schema name.
@Serializable()
class Address {
@JSONProperty() street!: string;
@JSONProperty() city!: string;
}
@Serializable()
class Person {
@JSONProperty() name!: string;
@JSONProperty({ type: () => Address }) address!: Address;
}
const person = deserialize(Person, {
name: 'Grace',
address: { street: '42 Broadway', city: 'New York' },
});
console.log(person.address instanceof Address); // true@Serializable()
class Order {
@JSONProperty({ type: () => LineItem, isArray: true })
items!: LineItem[];
}@Serializable()
class Catalog {
// Serialized as a plain object { 'sku-1': {...}, ... }
@JSONProperty({ type: () => Product, isMap: true })
products!: Map<string, Product>;
}@Serializable()
class TagGroup {
// Serialized as a plain array ["foo", "bar"]
@JSONProperty({ type: String, isSet: true })
tags!: Set<string>;
}JavaScript Date and URL objects are supported natively without custom transforms.
@Serializable()
class Event {
@JSONProperty({ type: Date })
startDate!: Date;
@JSONProperty({ type: URL })
link!: URL;
}
const ev = deserialize(Event, {
startDate: '2026-03-20T11:23:46.000Z',
link: 'https://example.com/foo'
});
console.log(ev.startDate.getFullYear()); // 2026
console.log(ev.link.pathname); // /fooenum Status {
Active = 'ACTIVE',
Inactive = 'INACTIVE'
}
enum Priority {
Low = 0,
Medium = 1,
High = 2
}
@Serializable()
class Task {
@JSONProperty()
title!: string;
@JSONProperty({ type: () => Status })
status!: Status;
@JSONProperty({ type: () => Priority })
priority!: Priority;
}
const task = deserialize(Task, {
title: 'Fix bug',
status: 'ACTIVE',
priority: 1
});
console.log(task.status); // 'ACTIVE'
console.log(task.priority); // 1
const plain = serialize(task); // { title: 'Fix bug', status: 'ACTIVE', priority: 1 }@Serializable()
class Settings {
@JSONProperty({ defaultValue: 'light' }) theme!: string;
@JSONProperty({ defaultValue: () => [] }) tags!: string[]; // factory for safe mutable defaults
}@Serializable()
class Config {
@JSONProperty({ optional: false })
apiKey!: string; // throws SerializationError if missing
}@Serializable()
class Product {
@JSONProperty({ validate: (v: number) => v > 0 || 'Price must be positive' })
price!: number;
}@Serializable()
class Record {
@JSONProperty({ nullable: 'null' }) mayBeNull!: string | null; // preserved
@JSONProperty({ nullable: 'ignore' }) skipNull?: string; // omitted (default)
@JSONProperty({ nullable: 'error' }) mustExist!: string; // throws
}@Serializable()
class Animal {
@JSONProperty() name!: string;
}
@Serializable()
class Pet extends Animal {
@JSONProperty() ownerName!: string;
// `name` is inherited and still serialized
}const copy = clone(User, user); // deep-independent copy
const updated = patch(User, user, { age: 37 }); // non-destructive updatePatch keys may use property names, canonical JSON names, or aliases, including with strictPatch: true. If several names for one field appear in the patch, the property name wins, then the canonical JSON name, then the first declared alias.
Run yarn lint to lint src with Oxlint, or yarn check for source and test type checks, linting, unit tests, and package consumer checks.
.oxlintrc.json preserves the supported ESLint rule severities and TypeScript overrides. Type-aware linting uses oxlint-tsgolint to retain the dot-notation check; explicit rootDir settings support its TypeScript compiler. The former eol-last formatting warning is omitted because Oxlint has no native equivalent. See the Oxlint migration guide.
All library, parser, migration, codec, transform, validation, and constructor failures are exposed as SerializationError. Native failures are preserved in .cause; .path uses JSONPath-style notation:
import { SerializationError } from '@depthbomb/serde';
try {
deserialize(User, {});
} catch (err) {
if (err instanceof SerializationError) {
console.log(err.message); // [@depthbomb/serde] Missing required property '...' (at '$.fieldName')
console.log(err.path); // '$.fieldName'
}
}