API Stability Notice

Macroforge is under active development. The API is not yet stable and may change between versions. Some documentation sections may be outdated.

Serialize

The Serialize macro generates a toJSON() method that converts your object to a JSON-compatible format with automatic handling of complex types like Date, Map, Set, and nested objects.

Basic Usage

Before (Your Code)
TypeScript
/** @derive(Serialize) */
class User {
    name: string;
    age: number;
    createdAt: Date;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
        this.createdAt = new Date();
    }
}
After (Generated)
TypeScript
import { SerializeContext } from 'macroforge/serde';

class User {
    name: string;
    age: number;
    createdAt: Date;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
        this.createdAt = new Date();
    }

    toStringifiedJSON(): string {
        const ctx = SerializeContext.create();
        return JSON.stringify(this.__serialize(ctx));
    }

    toJSON(): Record<string, unknown> {
        const ctx = SerializeContext.create();
        return this.__serialize(ctx);
    }

    __serialize(ctx: SerializeContext): Record<string, unknown> {
        const existingId = ctx.getId(this);
        if (existingId !== undefined) {
            return {
                __ref: existingId
            };
        }
        const __id = ctx.register(this);
        const result: Record<string, unknown> = {
            __type: 'User',
            __id
        };
        result['name'] = this.name;
        result['age'] = this.age;
        result['createdAt'] = this.createdAt.toISOString();
        return result;
    }
}
TypeScript
const user = new User("Alice", 30);
console.log(JSON.stringify(user));
// {"name":"Alice","age":30,"createdAt":"2024-01-15T10:30:00.000Z"}

Automatic Type Handling

Serialize automatically handles various TypeScript types:

TypeSerialization
string, number, booleanDirect copy
Date.toISOString()
T[]Maps items, calling toJSON() if available
Map<K, V>Object.fromEntries()
Set<T>Array.from()
Nested objectsCalls toJSON() if available

Serde Options

Use the @serde decorator for fine-grained control over serialization:

Renaming Fields

Before (Your Code)
TypeScript
/** @derive(Serialize) */
class User {
    /** @serde({ rename: "user_id" }) */
    id: string;

    /** @serde({ rename: "full_name" }) */
    name: string;
}
After (Generated)
TypeScript
import { SerializeContext } from 'macroforge/serde';

class User {
    id: string;

    name: string;

    toStringifiedJSON(): string {
        const ctx = SerializeContext.create();
        return JSON.stringify(this.__serialize(ctx));
    }

    toJSON(): Record<string, unknown> {
        const ctx = SerializeContext.create();
        return this.__serialize(ctx);
    }

    __serialize(ctx: SerializeContext): Record<string, unknown> {
        const existingId = ctx.getId(this);
        if (existingId !== undefined) {
            return {
                __ref: existingId
            };
        }
        const __id = ctx.register(this);
        const result: Record<string, unknown> = {
            __type: 'User',
            __id
        };
        result['user_id'] = this.id;
        result['full_name'] = this.name;
        return result;
    }
}
TypeScript
const user = new User();
user.id = "123";
user.name = "Alice";
console.log(JSON.stringify(user));
// {"user_id":"123","full_name":"Alice"}

Skipping Fields

Before (Your Code)
TypeScript
/** @derive(Serialize) */
class User {
    name: string;
    email: string;

    /** @serde({ skip: true }) */
    password: string;

    /** @serde({ skip_serializing: true }) */
    internalId: string;
}
After (Generated)
TypeScript
import { SerializeContext } from 'macroforge/serde';

class User {
    name: string;
    email: string;

    password: string;

    internalId: string;

    toStringifiedJSON(): string {
        const ctx = SerializeContext.create();
        return JSON.stringify(this.__serialize(ctx));
    }

    toJSON(): Record<string, unknown> {
        const ctx = SerializeContext.create();
        return this.__serialize(ctx);
    }

    __serialize(ctx: SerializeContext): Record<string, unknown> {
        const existingId = ctx.getId(this);
        if (existingId !== undefined) {
            return {
                __ref: existingId
            };
        }
        const __id = ctx.register(this);
        const result: Record<string, unknown> = {
            __type: 'User',
            __id
        };
        result['name'] = this.name;
        result['email'] = this.email;
        return result;
    }
}
skip vs skip_serializing
Use skip: true to exclude from both serialization and deserialization. Use skip_serializing: true to only skip during serialization.

Rename All Fields

Apply a naming convention to all fields at the container level:

Source
TypeScript
/** @derive(Serialize) */
/** @serde({ rename_all: "camelCase" }) */
class ApiResponse {
  user_name: string;
  created_at: Date;
  is_active: boolean;
}

Supported conventions:

  • camelCase
  • snake_case
  • PascalCase
  • SCREAMING_SNAKE_CASE
  • kebab-case

Flattening Nested Objects

Source
TypeScript
/** @derive(Serialize) */
class Address {
  city: string;
  zip: string;
}

/** @derive(Serialize) */
class User {
  name: string;

  /** @serde({ flatten: true }) */
  address: Address;
}
TypeScript
const user = new User();
user.name = "Alice";
user.address = { city: "NYC", zip: "10001" };
console.log(JSON.stringify(user));
// {"name":"Alice","city":"NYC","zip":"10001"}

All Options

Container Options (on class/interface)

OptionTypeDescription
rename_allstringApply naming convention to all fields

Field Options (on properties)

OptionTypeDescription
renamestringUse a different JSON key
skipbooleanExclude from serialization and deserialization
skip_serializingbooleanExclude from serialization only
flattenbooleanMerge nested object fields into parent

Interface Support

Serialize also works with interfaces. For interfaces, a namespace is generated with a toJSON function:

Before (Your Code)
TypeScript
/** @derive(Serialize) */
interface ApiResponse {
    status: number;
    message: string;
    timestamp: Date;
}
After (Generated)
TypeScript
import { SerializeContext } from 'macroforge/serde';

interface ApiResponse {
    status: number;
    message: string;
    timestamp: Date;
}

export namespace ApiResponse {
    export function toStringifiedJSON(self: ApiResponse): string {
        const ctx = SerializeContext.create();
        return JSON.stringify(__serialize(self, ctx));
    }
    export function __serialize(self: ApiResponse, ctx: SerializeContext): Record<string, unknown> {
        const existingId = ctx.getId(self);
        if (existingId !== undefined) {
            return { __ref: existingId };
        }
        const __id = ctx.register(self);
        const result: Record<string, unknown> = { __type: 'ApiResponse', __id };
        result['status'] = self.status;
        result['message'] = self.message;
        result['timestamp'] = self.timestamp.toISOString();
        return result;
    }
}
TypeScript
const response: ApiResponse = {
  status: 200,
  message: "OK",
  timestamp: new Date()
};

console.log(JSON.stringify(ApiResponse.toJSON(response)));
// {"status":200,"message":"OK","timestamp":"2024-01-15T10:30:00.000Z"}

Enum Support

Serialize also works with enums. The toJSON function returns the underlying enum value (string or number):

Before (Your Code)
TypeScript
/** @derive(Serialize) */
enum Status {
    Active = 'active',
    Inactive = 'inactive',
    Pending = 'pending'
}
After (Generated)
TypeScript
enum Status {
    Active = 'active',
    Inactive = 'inactive',
    Pending = 'pending'
}

export namespace Status {
    export function toStringifiedJSON(value: Status): string {
        return JSON.stringify(value);
    }
    export function __serialize(_ctx: SerializeContext): string | number {
        return value;
    }
}
TypeScript
console.log(Status.toJSON(Status.Active));  // "active"
console.log(Status.toJSON(Status.Pending)); // "pending"

Works with numeric enums too:

Source
TypeScript
/** @derive(Serialize) */
enum Priority {
  Low = 1,
  Medium = 2,
  High = 3,
}
TypeScript
console.log(Priority.toJSON(Priority.High)); // 3

Type Alias Support

Serialize works with type aliases. For object types, fields are serialized with full type handling:

Before (Your Code)
TypeScript
/** @derive(Serialize) */
type UserProfile = {
    id: string;
    name: string;
    createdAt: Date;
};
After (Generated)
TypeScript
import { SerializeContext } from 'macroforge/serde';

type UserProfile = {
    id: string;
    name: string;
    createdAt: Date;
};

export namespace UserProfile {
    export function toStringifiedJSON(value: UserProfile): string {
        const ctx = SerializeContext.create();
        return JSON.stringify(__serialize(value, ctx));
    }
    export function __serialize(
        value: UserProfile,
        ctx: SerializeContext
    ): Record<string, unknown> {
        const existingId = ctx.getId(value);
        if (existingId !== undefined) {
            return { __ref: existingId };
        }
        const __id = ctx.register(value);
        const result: Record<string, unknown> = { __type: 'UserProfile', __id };
        result['id'] = value.id;
        result['name'] = value.name;
        result['createdAt'] = value.createdAt;
        return result;
    }
}
TypeScript
const profile: UserProfile = {
  id: "123",
  name: "Alice",
  createdAt: new Date("2024-01-15")
};

console.log(JSON.stringify(UserProfile.toJSON(profile)));
// {"id":"123","name":"Alice","createdAt":"2024-01-15T00:00:00.000Z"}

For union types, the value is returned directly:

Source
TypeScript
/** @derive(Serialize) */
type ApiStatus = "loading" | "success" | "error";
TypeScript
console.log(ApiStatus.toJSON("success")); // "success"

Combining with Deserialize

Use both Serialize and Deserialize for complete JSON round-trip support:

Source
TypeScript
/** @derive(Serialize, Deserialize) */
class User {
  name: string;
  createdAt: Date;
}
TypeScript
// Serialize
const user = new User();
user.name = "Alice";
user.createdAt = new Date();
const json = JSON.stringify(user);

// Deserialize
const parsed = User.fromJSON(JSON.parse(json));
console.log(parsed.createdAt instanceof Date); // true