Skip to main content
Version: dev

Custom Serializers

Use a custom serializer when a target needs a custom encoding or cannot meet the direct-access requirements of an external structural serializer.

A custom serializer is not limited to external types:

  • A target that itself conforms to Serializer with Target == Self selects that implementation implicitly everywhere.
  • A separate serializer whose Target is another type must be selected explicitly everywhere it is needed.

The same selection rules apply to roots, generated fields, optionals, arrays, sets, and dictionaries. Register a separate serializer and select it explicitly where it is used.

When to Use a Custom Serializer

  • The target has private or immutable state.
  • The target must enforce construction invariants.
  • The target needs a specialized compact encoding.
  • The target needs custom validation or construction logic.
  • An external enum is not exhaustively switchable.
  • An external union cannot represent UnknownCase.

User-Owned Targets

When you own the target, implement Serializer on the target itself and set Target to the same type:

import Fory

struct AccountID: Serializer, Equatable {
typealias Target = AccountID

let rawValue: UInt64

static var staticTypeId: TypeId {
.ext
}

static func writeData(
_ value: AccountID,
_ context: WriteContext
) throws {
try UInt64.writeData(value.rawValue, context)
}

static func readData(
_ context: ReadContext
) throws -> AccountID {
AccountID(rawValue: try UInt64.readData(context))
}
}

Register and use the type through the ordinary root APIs:

let fory = Fory()
try fory.register(AccountID.self, id: 300)

let input = AccountID(rawValue: 42)
let data = try fory.serialize(input)
let output: AccountID = try fory.deserialize(data)

assert(input == output)

No with: argument is needed because AccountID.Target == AccountID.

One Global Serializer for an External Type

Swift permits an application to make an external type implement Serializer through a retroactive conformance:

import Foundation
import Fory

extension UUID: @retroactive Serializer {
public typealias Target = UUID

public static var staticTypeId: TypeId {
.ext
}

public static func defaultValue(
_ context: ReadContext
) throws -> UUID {
_ = context
return UUID(
uuidString: "00000000-0000-0000-0000-000000000000"
)!
}

public static func writeData(
_ value: UUID,
_ context: WriteContext
) throws {
try String.writeData(value.uuidString, context)
}

public static func readData(
_ context: ReadContext
) throws -> UUID {
let raw = try String.readData(context)
guard let uuid = UUID(uuidString: raw) else {
throw ForyError.invalidData("invalid UUID string: \(raw)")
}
return uuid
}
}

Register the external type itself:

try fory.register(UUID.self, id: 300)

let input = UUID()
let data = try fory.serialize(input)
let output: UUID = try fory.deserialize(data)

Because UUID.Target == UUID, unannotated generated fields and ordinary carriers also select this implementation:

@ForyStruct
struct Request {
var requestID: UUID
}

let input = [UUID(), UUID()]
let data = try fory.serialize(input)
let output: [UUID] = try fory.deserialize(data)

A retroactive conformance applies to the entire process. Swift allows only one Serializer conformance for a given type; @retroactive acknowledges the compiler warning but does not make competing conformances safe. Use this form only when the application intentionally chooses the single global implementation. Public libraries should generally provide a separate serializer instead.

Separate Serializers

Use a separate serializer when a public library must not claim a process-global conformance or when an application needs multiple or alternative implementations. The target may be external or user-owned:

import Foundation
import Fory

public enum UUIDStringSerializer: Serializer {
public typealias Target = UUID

public static var staticTypeId: TypeId {
.ext
}

public static func defaultValue(
_ context: ReadContext
) throws -> UUID {
_ = context
return UUID(
uuidString: "00000000-0000-0000-0000-000000000000"
)!
}

public static func writeData(
_ value: UUID,
_ context: WriteContext
) throws {
try String.writeData(value.uuidString, context)
}

public static func readData(
_ context: ReadContext
) throws -> UUID {
let raw = try String.readData(context)
guard let uuid = UUID(uuidString: raw) else {
throw ForyError.invalidData("invalid UUID string: \(raw)")
}
return uuid
}
}

Register the separate serializer and select it explicitly at the root:

let fory = Fory()
try fory.register(UUIDStringSerializer.self, id: 300)

let input = UUID()
let data = try fory.serialize(input, with: UUIDStringSerializer.self)
let output = try fory.deserialize(data, with: UUIDStringSerializer.self)

assert(input == output)

Another declaration, such as UUIDBytesSerializer, may target the same type with a different body. Fory cannot choose between separate serializers automatically. Select the desired serializer with with: at roots and with the matching field annotation. Register only one implementation for the target on a given Fory instance.

The direct Any and AnyObject root conveniences remain dynamic operations. A registered serializer may be used for a concrete value passed as Any, but typed roots and fields still require with: for a separate serializer.

Fields and Carriers

A field whose type directly implements Serializer with Target == Self needs no selector:

@ForyStruct
struct Request {
var accountID: AccountID
}

A separate serializer must be selected explicitly:

@ForyStruct
struct ExternalRequest {
@ForyField(with: UUIDStringSerializer.self)
var requestID: UUID
}

Ordinary carriers containing types that directly implement Serializer need no selector. This includes intentional retroactive conformances:

let accountIDs = [
AccountID(rawValue: 1),
AccountID(rawValue: 2),
]
let data = try fory.serialize(accountIDs)
let output: [AccountID] = try fory.deserialize(data)

For an element that uses a separate serializer, name it in the carrier annotation:

@ListField(element: .with(UUIDStringSerializer.self))
var requestIDs: [UUID]

At a root, use the matching carrier serializer:

let data = try fory.serialize(
requestIDs,
with: ArraySerializer<UUIDStringSerializer>.self
)

Custom Serializer Rules

A custom serializer must return .ext from staticTypeId. The .structType, .enumType, and .typedUnion values are reserved for @ForyStruct, @ForyEnum, and @ForyUnion.

writeData and readData process only the target body. Do not call a root serialize or deserialize method from either operation.

Defaults

Implement defaultValue(_:) only when the target has a valid value for a null or missing field.

Input Validation

Reject invalid input with an appropriate ForyError.

Custom Class Serializers

For a cyclic class, override the complete-value read operation and use Fory's reference APIs so repeated references resolve to the same object.