A Swift macro package for crash-safe, schema-evolvable state serialization.
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:)withdecodeIfPresentfor 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.
JCState solves this by generating a three-layer type system via a Swift macro:
- Your struct (the "implementation type") — clean, non-optional properties with default values. This is the type you use in your app code.
Serializable(generated) — aCodablemirror where every field is optional. Missing keys in old serialized data simply decode asnilinstead of crashing.Concrete(generated) — a fully-resolved form that merges deserialized values with defaults. Any field that wasnilin theSerializable(because it was missing from old data) gets filled in with the default value fromConcrete.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.
Each stored property must:
- Have an explicit type annotation (e.g.,
var count: Int = 0, notvar 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
CodableandEquatable(or itself be a@JCStatestruct).
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.
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}.
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.
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
}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.
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.
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:
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:)usesencodeIfPresent, so default-valued fields are omitted from the output. - Custom
init(from:)usesdecodeIfPresent, so missing keys decode asnilinstead of throwing.
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.defaultis built from the initial values you provide in the struct declaration.
var toSerializable: Serializable— converts the struct to its serializable form (viaConcrete).init(fromConcrete:)— constructs the struct from a fully-resolvedConcretevalue.init()— default initializer.- Memberwise
init(count:name:score:).
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.
| 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) |
Serialization (MyState -> JSON):
JSONEncoder().encode(myState)calls the macro-generatedencode(to:).encode(to:)converts toSerializableviamyState.toSerializable, which creates aConcretefrom current values, then converts toSerializable, omitting fields that matchConcrete.default.- The
Serializableis encoded, producing sparse JSON.
Deserialization (JSON -> MyState):
JSONDecoder().decode(MyState.self, from: data)calls the macro-generatedinit(from:).init(from:)decodes intoSerializable(all-optional fields — missing keys becomenil).Serializable.toConcreteresolves eachnilagainstConcrete.default, producing a fully-populatedConcrete.- The
Concreteis passed toinit(fromConcrete:)to produce the finalMyState.
See EXAMPLE.md for a full macro expansion for an example struct.
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"]
)- Swift 6.2+
- swift-syntax 602.0.0
- macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / Mac Catalyst 13+
If you are an AI agent modifying code in a project that uses JCState:
- When adding a new persisted property to a
@JCStatestruct, always provide a default value. The default is used as the fallback when deserializing data that predates the new field. - Do not manually conform
@JCStatestructs toCodable. The macro automatically generatesCodableconformance on the struct itself (routing through the innerSerializabletype). Adding your ownCodableconformance will conflict. - Do not write custom
init(from:)orencode(to:)on@JCStatestructs. The macro generates these on both the struct andSerializable. - To serialize: encode the struct directly with
JSONEncoder().encode(myStruct). - To deserialize: decode the struct directly with
JSONDecoder().decode(MyType.self, from: data). - Nested
@JCStatetypes 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
nilis 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.