- Published on
- 5 min read Intermediate
> Creating a @DefaultEmpty Property Wrapper for Codable in Swift
// What_You_Will_Learn
- Build a reusable property wrapper for Codable types
- Handle missing and null JSON fields gracefully
- Keep Codable models clean without custom init(from:) overrides
If you've worked with JSON APIs for any length of time, you've run into this: an array field that sometimes comes back as null, sometimes is missing entirely, and sometimes is actually there. Your model uses [Item], but the decoder throws because the key is absent. So you switch to [Item]?, and now every call site needs nil-coalescing or optional binding just to iterate over what should be an empty list.
There's a cleaner way to handle this with a property wrapper. Instead of scattering ?? [] throughout your code, you can annotate the property once and let the decoder do the right thing.
The Problem
Consider a simple API response where a user has a list of tags:
struct User: Decodable {
let name: String
let tags: [String]
}
This works fine when the JSON includes "tags": ["swift", "ios"]. But if the API returns "tags": null or omits the field entirely, JSONDecoder throws. The usual fix is making the property optional:
struct User: Decodable {
let name: String
let tags: [String]?
}
Now every place you use tags needs to deal with the optional. You end up writing user.tags ?? [] or user.tags?.forEach { ... } repeatedly, and it clutters the code for something that's conceptually just "a list that might be empty."
Building the Property Wrapper
The idea is simple: wrap the array so that decoding treats both null and missing keys as an empty array. Here's the full implementation:
@propertyWrapper
struct DefaultEmpty<T: Codable>: Codable {
var wrappedValue: [T]
init(wrappedValue: [T] = []) {
self.wrappedValue = wrappedValue
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
wrappedValue = []
} else {
wrappedValue = try container.decode([T].self)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(wrappedValue)
}
}
The init(from:) method does the heavy lifting. It checks whether the value is null using decodeNil(), and if so, defaults to an empty array. Otherwise it decodes normally.
But there's one more piece. When the key is completely absent from the JSON, Decodable won't even call init(from:). It just fails because the key doesn't exist. To handle missing keys, you need to extend KeyedDecodingContainer:
extension KeyedDecodingContainer {
func decode<T: Codable>(
_ type: DefaultEmpty<T>.Type,
forKey key: Key
) throws -> DefaultEmpty<T> {
if let value = try decodeIfPresent(type, forKey: key) {
return value
}
return DefaultEmpty()
}
}
This tells the decoder: if the key isn't present, just return a DefaultEmpty with an empty array instead of throwing.
Using It
With the property wrapper in place, your model stays clean:
struct User: Codable {
let name: String
@DefaultEmpty var tags: [String]
}
And it handles every scenario you'd encounter from an API:
let decoder = JSONDecoder()
// Normal array
let json1 = #"{"name": "Alice", "tags": ["swift", "ios"]}"#.data(using: .utf8)!
let user1 = try decoder.decode(User.self, from: json1)
print(user1.tags) // ["swift", "ios"]
// Null value
let json2 = #"{"name": "Bob", "tags": null}"#.data(using: .utf8)!
let user2 = try decoder.decode(User.self, from: json2)
print(user2.tags) // []
// Missing key
let json3 = #"{"name": "Charlie"}"#.data(using: .utf8)!
let user3 = try decoder.decode(User.self, from: json3)
print(user3.tags) // []
No optionals, no nil-coalescing. The property is always a real array.
How It Works Under the Hood
The property wrapper approach works because of how Swift's Codable synthesis interacts with property wrappers. When the compiler generates the init(from:) for User, it sees that tags is wrapped by DefaultEmpty and calls the KeyedDecodingContainer.decode(_:forKey:) method for that type. Your extension intercepts this call and provides the fallback behavior.
The singleValueContainer approach in the wrapper's own init(from:) is what lets it decode transparently. The wrapper acts as a pass-through: it decodes the same JSON value that [T] would, just with the added null-handling logic.
Working with Equatable and Hashable
If your model needs to conform to Equatable or Hashable, you'll want DefaultEmpty to support those protocols too. Since it's a generic wrapper, you can add conditional conformances:
extension DefaultEmpty: Equatable where T: Equatable {}
extension DefaultEmpty: Hashable where T: Hashable {}
Swift can synthesize both conformances automatically because Array already conforms to Equatable and Hashable when its element type does.
A Note on Encoding
The wrapper encodes normally. If the array is empty, it writes [] to the JSON output rather than omitting the key or writing null. This is usually what you want since it keeps the encoding predictable and round-trippable. If you need different encoding behavior for empty arrays, you could customize the encode(to:) method, but in practice the default works well.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Intercepting Network Requests in Swift with URLProtocol
URLProtocol is one of the oldest interception points in Foundation's networking stack. It lets you mock, log, or transform any URLSession request without touching the code that makes the request.
Async defer in Swift 6.4
SE-0493 finally lets you write defer { await cleanup() } in async functions, without spawning a detached task or threading cleanup logic through every return path.
Task Cancellation Shields in Swift 6.4
Swift 6.4's withTaskCancellationShield lets cleanup code run to completion even after a task has been cancelled, without spawning extra unstructured tasks.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.