NSInvalidArgumentException "Invalid top-level type in JSON writing" — customizing serialization fallback for non-property-list objects
0 reputation · 16 May 2021, 11:45 UTC
0 reputation · 16 May 2021, 11:45 UTC
Serialize object graphs that include custom model types without manually flattening them to property-list containers before calling JSONSerialization.
Foundation's JSONSerialization throws an NSInvalidArgumentException with the reason Invalid top-level type in JSON writing when the root object is not an NSArray or NSDictionary (or contains non-property-list values). The exception is raised before any data is written, the output stream is left untouched, and there is no delegate, closure, or protocol hook to supply a custom representation for unsupported types. Catching the exception is discouraged because Foundation treats it as a programmer error rather than a recoverable condition, and the exact reason string is not guaranteed to remain stable across OS releases.
JSONSerialization?JSONSerialization has no supported extension point for custom types. There is no delegate, no closure, no protocol, and no subclassing hook that lets you supply a JSON representation for an object it doesn't recognize. The API accepts only property-list types — NSDictionary, NSArray, NSString, NSNumber, NSNull, plus NSDate and NSData — and throws NSInvalidArgumentException ("Invalid top-level type in JSON writing") before writing anything when it encounters something else. This is treated as a programmer error, not a recoverable condition, so try/catch around it is not a supported fallback strategy.
Confirmed: the type restriction, the exception behavior, and the absence of a customization hook are long-standing, documented characteristics of the API across iOS and macOS releases. Inferred: Apple is unlikely to add such a hook, because since iOS 11 / macOS 10.13 the designated answer to "serialize my custom types" is JSONEncoder with Codable, not JSONSerialization.
For new code, use JSONEncoder. Conform your model types to Encodable and customize per-type behavior in encode(to:). Global policies — dates, data blobs, key casing — are handled by DateEncodingStrategy, DataEncodingStrategy, and KeyEncodingStrategy, so you don't reimplement validation or traversal at all.
If you are constrained to JSONSerialization (legacy codebase, Objective-C-heavy graph, or a dependency that expects Any dictionaries), the established pattern is a single conversion protocol that turns models into property-list objects before serialization:
protocol JSONRepresentable {
func jsonObject() -> Any // must return only property-list types
}
struct User: JSONRepresentable {
let id: Int
let name: String
func jsonObject() -> Any {
return ["id": id, "name": name]
}
}
func serialize(_ value: Any) throws -> Data {
let plist = normalize(value)
guard JSONSerialization.isValidJSONObject(plist) else {
throw SerializationError.unrepresentable
}
return try JSONSerialization.data(withJSONObject: plist)
}
func normalize(_ value: Any) -> Any {
switch value {
case let v as JSONRepresentable: return normalize(v.jsonObject())
case let a as [Any]: return a.map(normalize)
case let d as [String: Any]: return d.mapValues(normalize)
default: return value
}
}This duplicates only the conversion logic — the validation still lives in Foundation via isValidJSONObject(_:), which you call explicitly instead of relying on the thrown exception. That guard converts a crash into a typed error you control.
If you move to JSONEncoder, verify output compatibility: Date and Data need explicit strategies (defaults are seconds-since-reference-date and base64, not what JSONSerialization produced), and edge cases like non-conforming floats differ. A quick check is to encode a known fixture with both paths and diff the bytes.
Confirm the behavior in a unit test: pass a plain custom class instance to JSONSerialization.data(withJSONObject:) inside a test expecting a thrown exception, then conform the type to Codable and confirm JSONEncoder succeeds. This pins the assumption to your actual OS/SDK version rather than to the reason string, which is not guaranteed stable.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.