Skip to content

jazzychad/JCState

Repository files navigation

JCState (JazzyChad State)

A Swift macro package for crash-safe, schema-evolvable state serialization.

The Problem

The default approach to persisting Swift structs — conforming to Codable and encoding/decoding with JSONEncoder/JSONDecoder — is fragile in the face of schema evolution. Consider:

// v1: shipped and users have data on disk
struct AppSettings: Codable {
    var theme: String = "light"
    var fontSize: Int = 14
}

A few releases later, you add a field:

// v2: new field added
struct AppSettings: Codable {
    var theme: String = "light"
    var fontSize: Int = 14
    var accentColor: String = "blue"  // new!
}

When v2 of the app tries to decode data that was persisted by v1, JSONDecoder throws a DecodingError.keyNotFound because accentColor is missing from the serialized data. The app crashes at runtime.

The common workarounds are all unsatisfying:

  • Make every property optional: Pollutes your entire codebase with unwrapping logic. The "real" code shouldn't have to care that a field might be missing from old serialized data.
  • Write custom init(from:) with decodeIfPresent for every field: Tedious, error-prone boilerplate that must be manually maintained every time a field is added.
  • Versioned migration code: High complexity, easy to get wrong, grows forever.

The JCState Solution

JCState solves this by generating a three-layer type system via a Swift macro:

  1. Your struct (the "implementation type") — clean, non-optional properties with default values. This is the type you use in your app code.
  2. Serializable (generated) — a Codable mirror where every field is optional. Missing keys in old serialized data simply decode as nil instead of crashing.
  3. Concrete (generated) — a fully-resolved form that merges deserialized values with defaults. Any field that was nil in the Serializable (because it was missing from old data) gets filled in with the default value from Concrete.default.

The conversion chain is:

Implementation  <-->  Concrete  <-->  Serializable  <-->  JSON

All of the inner types, their boilerplate conversion logic, custom Codable conformance, and default value resolution are generated entirely by the macro. You never see or maintain them.

Usage

1. Annotate your struct with @JCState

Each stored property must:

  • Have an explicit type annotation (e.g., var count: Int = 0, not var count = 0). Macros operate on the source-level AST and cannot infer types.
  • Have a default value (which becomes the fallback for missing fields during deserialization).
  • Conform to Codable and Equatable (or itself be a @JCState struct).
import JCState

@JCState
struct AppSettings {
    var theme: String = "light"
    var fontSize: Int = 14
    var accentColor: String = "blue"
    var sidebarWidth: CGFloat = 250.0
    var showLineNumbers: Bool? = nil  // optional properties are supported
}

That's it. The macro generates everything else.

2. Serialize

Encode your struct directly — the macro automatically conforms it to Codable:

let settings = AppSettings(theme: "dark", fontSize: 16, accentColor: "blue",
                           sidebarWidth: 250.0)
let data = try JSONEncoder().encode(settings)

Sparse encoding: Only values that differ from the defaults are included in the serialized output. In the example above, accentColor and sidebarWidth match their defaults, so the JSON would only contain {"theme":"dark","fontSize":16}.

3. Deserialize

Decode directly into your struct:

let settings = try JSONDecoder().decode(AppSettings.self, from: data)

If the data was written by an older version of the app that didn't have accentColor or sidebarWidth, those fields simply get the default values "blue" and 250.0. No crash. No migration code.

Under the hood, the macro-generated Codable conformance routes through the Serializable type, so all the crash-safety guarantees apply transparently.

4. Evolve your schema freely

Add new fields at any time. Old persisted data continues to deserialize safely:

@JCState
struct AppSettings {
    var theme: String = "light"
    var fontSize: Int = 14
    var accentColor: String = "blue"
    var sidebarWidth: CGFloat = 250.0
    var showLineNumbers: Bool? = nil
    var editorFont: String = "SF Mono"       // added in v3 - old data just gets the default
    var tabSize: Int = 4                      // added in v3 - old data just gets the default
}

5. Nested JCState types

JCState supports recursive composition. If a property's type is itself a @JCState struct, the generated code correctly serializes and deserializes the nested type through its own three-layer chain:

@JCState
struct EditorConfig {
    var tabSize: Int = 4
    var wordWrap: Bool = true
}

@JCState
struct AppSettings {
    var theme: String = "light"
    var editor: EditorConfig = EditorConfig()  // nested JCState type
}

The macro detects nested JCState types at compile time via generic constraints and dispatches through the appropriate conversion helpers automatically.

6. Controlling sparse encoding

By default, @JCState uses sparse encoding — only values that differ from defaults are included in the serialized JSON. If you need every field to always be present in the output (e.g., for interop with an external system that expects all keys), pass sparse: false:

@JCState(sparse: false)
struct AppSettings {
    var theme: String = "light"
    var fontSize: Int = 14
    var accentColor: String = "blue"
}

With sparse: true (the default):

{"theme":"dark"}

With sparse: false:

{"theme":"dark","fontSize":14,"accentColor":"blue"}

Decoding is unaffected by this setting — missing keys are always handled gracefully regardless of how the data was encoded.

Note: The sparse flag is not recursive. Nested @JCState types use their own sparse setting, not the parent's. For example:

@JCState(sparse: false)
struct AppSettings {
    var theme: String = "light"
    var editor: EditorConfig = EditorConfig()  // uses EditorConfig's own sparse setting
}

@JCState  // sparse: true (the default)
struct EditorConfig {
    var tabSize: Int = 4
    var wordWrap: Bool = true
}

Here, AppSettings will encode all of its fields, but the nested EditorConfig will still use sparse encoding because its own @JCState macro defaults to sparse: true.

How It Works (Detailed)

What the macro generates

For a struct like:

@JCState
struct MyState {
    var count: Int = 0
    var name: String = "default"
    var score: Double? = nil
}

The @JCState macro generates the following inside MyState:

Serializable (nested struct)

public struct Serializable: JCSerializable, Codable, Equatable {
    let count: Int?
    let name: String?
    let score: Double?

    // Custom Codable implementation using decodeIfPresent for all fields
    // Conversion: toConcrete resolves nils against Concrete.default
}
  • All fields are optional regardless of whether they were optional in the original struct.
  • Custom encode(to:) uses encodeIfPresent, so default-valued fields are omitted from the output.
  • Custom init(from:) uses decodeIfPresent, so missing keys decode as nil instead of throwing.

Concrete (nested struct)

public struct Concrete: JCConcrete, Equatable {
    let count: Int
    let name: String
    let score: Double?  // stays optional because it was optional in the original

    static let `default` = Concrete(count: 0, name: "default", score: nil)

    // Conversion: toSerializable omits fields that match the default
    // Conversion: toImpl constructs MyState from resolved values
}
  • Non-optional fields from the original struct are non-optional here.
  • Optional fields from the original struct remain optional.
  • Concrete.default is built from the initial values you provide in the struct declaration.

Additional members on MyState

  • var toSerializable: Serializable — converts the struct to its serializable form (via Concrete).
  • init(fromConcrete:) — constructs the struct from a fully-resolved Concrete value.
  • init() — default initializer.
  • Memberwise init(count:name:score:).

Extensions

extension MyState: JCState {}
extension MyState: Codable {
    init(from decoder: any Decoder) throws { ... }
    func encode(to encoder: any Encoder) throws { ... }
}

The macro adds both JCState and Codable conformance. The generated Codable implementation routes through Serializable under the hood, so you can encode/decode MyState directly with JSONEncoder/JSONDecoder.

The three protocols

Protocol Conformed by Key requirements
JCState Your struct (via macro extension) init(fromConcrete:), toSerializable
JCConcrete Generated Concrete struct toSerializable, toImpl, static var default
JCSerializable Generated Serializable struct toConcrete (plus Codable)

Conversion chain in detail

Serialization (MyState -> JSON):

  1. JSONEncoder().encode(myState) calls the macro-generated encode(to:).
  2. encode(to:) converts to Serializable via myState.toSerializable, which creates a Concrete from current values, then converts to Serializable, omitting fields that match Concrete.default.
  3. The Serializable is encoded, producing sparse JSON.

Deserialization (JSON -> MyState):

  1. JSONDecoder().decode(MyState.self, from: data) calls the macro-generated init(from:).
  2. init(from:) decodes into Serializable (all-optional fields — missing keys become nil).
  3. Serializable.toConcrete resolves each nil against Concrete.default, producing a fully-populated Concrete.
  4. The Concrete is passed to init(fromConcrete:) to produce the final MyState.

See EXAMPLE.md for a full macro expansion for an example struct.

Installation

Add JCState as a Swift Package Manager dependency:

dependencies: [
    .package(url: "https://github.com/jazzychad/JCState.git", from: "0.0.6")
]

Then add "JCState" to the dependencies of the target that uses it:

.target(
    name: "MyApp",
    dependencies: ["JCState"]
)

Requirements

  • Swift 6.2+
  • swift-syntax 602.0.0
  • macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / Mac Catalyst 13+

AI Agent Guidelines

If you are an AI agent modifying code in a project that uses JCState:

  • When adding a new persisted property to a @JCState struct, always provide a default value. The default is used as the fallback when deserializing data that predates the new field.
  • Do not manually conform @JCState structs to Codable. The macro automatically generates Codable conformance on the struct itself (routing through the inner Serializable type). Adding your own Codable conformance will conflict.
  • Do not write custom init(from:) or encode(to:) on @JCState structs. The macro generates these on both the struct and Serializable.
  • To serialize: encode the struct directly with JSONEncoder().encode(myStruct).
  • To deserialize: decode the struct directly with JSONDecoder().decode(MyType.self, from: data).
  • Nested @JCState types work automatically. If a property's type is itself @JCState, the macro handles recursive serialization without additional work.
  • Do not make properties optional just to handle missing data. That's what JCState does for you. Only use optional types when nil is a meaningful value in your domain.
  • Sparse encoding is on by default. Use @JCState(sparse: false) only when the consumer of the JSON requires all keys to be present. Prefer the default sparse mode for storage and internal use.

About

A Swift macro package for crash-safe, schema-evolvable state serialization.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages