JSON and Serialization: NSJSONSerialization, Model Mapping, and Untrusted Data

Learn to encode and decode JSON in Objective-C with NSJSONSerialization, understand how JSON's object model maps onto Foundation types (including the NSNull gotcha), map decoded data into strongly-typed model objects, and handle malformed or untrusted JSON safely.

Once a network response comes back as raw NSData, as covered in the previous lesson, it almost always needs to be turned into something the app can actually work with — and on the modern web, that data is overwhelmingly JSON. Objective-C's bridge between raw JSON text and usable Foundation objects is NSJSONSerialization, a class with a small API surface but a lot of important behavior packed into it: how it maps JSON's few data types onto NSDictionary, NSArray, NSString, NSNumber, and — critically — NSNull, how it reports failure through the same NSError out-parameter pattern used throughout Foundation, and how far short it falls of validating that the data is actually shaped the way your code expects. This lesson works through encoding, decoding, the gotchas that trip up real code, hand-mapping JSON into model objects, and the broader discipline of treating any JSON that came from outside your own app as data to be validated, not trusted.

NSJSONSerialization: Decoding Data into Foundation Objects

NSJSONSerialization's core decoding method, exactly as the spec presents it, is JSONObjectWithData:options:error:. It takes the raw NSData containing JSON text and returns id — deliberately untyped, because the top level of a JSON document can be either an object (which becomes an NSDictionary) or an array (which becomes an NSArray), and the method has no way to know in advance which one a given payload will be.

That id return type is not a minor detail; it is the single most important thing to internalize about this API. Code that blindly does NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:...] and then starts calling dictionary methods on it will crash at runtime — not compile-fail, crash — the moment the server (or a malformed local file) returns a JSON array at the top level instead of an object. Correct code checks what it actually got back with isKindOfClass: before treating it as a particular type.

The options: parameter accepts flags like NSJSONReadingMutableContainers (return mutable NSMutableDictionary/NSMutableArray instead of immutable ones) and NSJSONReadingFragmentsAllowed (permit a top-level JSON value that is not itself an object or array, such as a bare string or number); passing 0 — as the spec's example does — takes the standard, immutable-containers behavior, which is the right default for most code that only reads the decoded structure.

The NSError ** Out-Parameter Pattern

The spec's example is short but demonstrates a pattern used throughout Foundation, and this lesson's next module covers it in full: NSError *error = nil; id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];. The method's error: parameter is a pointer to a pointer — NSError ** — which lets the method populate your local error variable with a description of what went wrong, if anything did, while still returning normally (as opposed to throwing).

The pattern to internalize now, ahead of the dedicated error-handling lesson, is this: declare an NSError *error = nil; local, pass &error (the address of that pointer) to the method, and afterward check whether error is still nil. If parsing failed, object will be nil and error will describe why; if parsing succeeded, object will be non-nil and error will remain nil. Checking only one of the two — assuming success because object looks non-nil, or ignoring error entirely — is exactly the kind of shortcut that lets malformed JSON silently corrupt program state further downstream.

It's worth noting why error must be initialized to nil before the call: if JSONObjectWithData:... succeeds, it may simply leave the error pointer untouched, in which case an uninitialized local would contain garbage that looks like a real, misleading error object.

How JSON's Object Model Maps onto Foundation

JSON has a small, fixed set of value types, and NSJSONSerialization maps each one onto a specific Foundation class. A JSON object ({ "key": "value" }) becomes an NSDictionary, with NSString keys. A JSON array ([1, 2, 3]) becomes an NSArray. JSON strings become NSString. JSON numbers — whether they look like integers or have a decimal point — become NSNumber, a single class that can hold either an integer or a floating-point value internally. JSON booleans (true/false) also become NSNumber, wrapping a BOOL.

This mapping is close to seamless for objects, arrays, and strings, but it has two sharp edges worth calling out on their own: what happens to JSON null, and the way NSNumber can blur the line between integers and doubles. Both are covered in the next two sections, because both are genuinely common sources of real bugs.

The NSNull Gotcha: JSON null Is Not Objective-C nil

This is one of the most consistently surprising things about NSJSONSerialization for developers coming from languages where 'no value' is represented uniformly. A JSON null value does not decode to Objective-C nil — it decodes to [NSNull null], a real, singleton object that specifically represents 'a null was present here.' The reason is structural: Foundation collections like NSDictionary and NSArray cannot store nil as an element or value at all (inserting nil into an NSMutableArray or as an NSDictionary value is itself a crash), so there needs to be some non-nil object that stands in for 'null', and NSNull is it.

The practical trap this creates is code that reads a value out of a decoded dictionary and assumes a 'missing' key and an explicitly-null key behave the same way. They do not: dict[@"middleName"] for a genuinely absent key returns nil, which most code handles gracefully (an if (value) check is false, string interpolation with nil prints '(null)' rather than crashing in many contexts) — but if the key is present with a JSON value of null, dict[@"middleName"] returns [NSNull null], a real object, so an if (value) check is true, and code that then tries to use it as an NSString will crash or misbehave, because it's an NSNull, not a string.

The fix is straightforward but must be applied deliberately: check if (value && value != [NSNull null]), or, equivalently, check [value isKindOfClass:[NSNull class]] before treating a decoded value as real data. This is not an edge case worth skipping — any JSON schema with optional fields will eventually send explicit nulls for them, and code that isn't written to expect NSNull will eventually crash on real production data.

Numeric Values: NSNumber Hides Int-vs-Double Distinctions

Every JSON number, whether it was written as 42 or 42.5 in the source text, decodes to an NSNumber — a single class capable of wrapping a variety of underlying numeric types (int, long long, double, BOOL, and others). This is convenient in that your code never has to guess which numeric decoding method to call, but it also means the distinction between 'this was an integer' and 'this was a floating-point value' is not always as crisp as it might seem from reading the original JSON text.

In practice, this rarely causes serious problems as long as you read a numeric NSNumber with the accessor appropriate for how you intend to use it — .integerValue when you need a whole number, .doubleValue when you need a float — rather than assuming a particular underlying storage type. Where it can genuinely bite is around precision: very large integers, or numbers with more significant digits than a double can represent exactly, can lose precision when funneled through NSNumber's double-based internal representation, which is worth knowing if your API deals with things like large numeric identifiers that must round-trip exactly.

Encoding: dataWithJSONObject:options:error:

The reverse operation — turning Foundation objects back into JSON text — is NSJSONSerialization dataWithJSONObject:options:error:, which takes an NSDictionary or NSArray (built out of JSON-representable leaf types: NSString, NSNumber, NSArray, NSDictionary, and NSNull) and returns NSData containing the encoded JSON text, again reporting failure through an NSError ** out-parameter rather than throwing.

Not every Objective-C object is JSON-representable, and dataWithJSONObject:options:error: will fail — returning nil and populating error — if you hand it something it can't encode: an NSDate, a custom model object, or a dictionary whose keys aren't strings, for example. This is why model objects generally need an explicit conversion step (covered below) into a plain dictionary-of-JSON-safe-values before being handed to this method, rather than being passed directly.

Mapping Decoded JSON into a Model Object

Raw NSDictionary access — dict[@"name"], [dict[@"age"] integerValue] — works, but scattering it throughout an app's business logic is exactly the kind of 'stringly-typed' code that's easy to get subtly wrong (a typo'd key silently returns nil instead of failing to compile) and hard to refactor. The standard remedy in Objective-C is to write a small, explicit model class with a class or instance method that maps a decoded dictionary into strongly-typed properties, once, in one place.

This mapping method is also exactly where the NSNull and type-checking discipline from earlier in this lesson belongs — it's the single choke point where untrusted, loosely-typed dictionary data gets converted into the strongly-typed shape the rest of the app can rely on, so it's the natural place to validate types and substitute sensible defaults for missing or null fields.

Custom Serialization and the Codable Comparison

Beyond a simple decode-into-properties method, many Objective-C model classes also implement the reverse — an instance method like - (NSDictionary *)toDictionary — so the object can serialize itself back to a JSON-safe dictionary when it needs to be sent to a server. Between a + (instancetype)userWithDictionary: class method and a - (NSDictionary *)toDictionary instance method, a model class ends up with full, explicit control over both directions of the JSON boundary — which is exactly the point: Objective-C does not give you this for free, you write it by hand, once per model.

It's genuinely useful to contrast this with Swift's Codable, since Objective-C and Swift code frequently sit side by side in the same real app. A Swift type that conforms to Codable gets its encode and decode logic generated by the compiler from its property declarations — type-safe, checked at compile time, no stringly-typed dictionary keys anywhere. Objective-C's approach, by contrast, is manual and stringly-typed from top to bottom: you write the dictionary-key lookups yourself, and a typo in a key string is a runtime bug, not a compiler error. This isn't a flaw unique to bad code; it's a structural difference between the languages, and it's one of the concrete ergonomics gaps developers feel when moving between an older Objective-C codebase and newer Swift code within the same app.

Secure Handling of Untrusted JSON

Every piece of JSON that arrives from a network response — or from any other source outside your own app's direct control — should be treated as untrusted input: data whose shape, types, and presence of fields your code should verify rather than assume. A well-behaved server today can still send a malformed response during an outage, a schema change on the backend can silently start omitting a field your app assumed was always present, and in the worst case a compromised or malicious endpoint can send JSON specifically crafted to break assumptions your parsing code made.

In practice this means: always check the error from JSONObjectWithData:options:error: before touching the result; always confirm the top-level object is the type you expect (isKindOfClass:[NSDictionary class] or [NSArray class]) rather than force-casting it; always guard against NSNull and missing keys when reading individual fields; and never assume a numeric field, an array's length, or a string's content falls within any particular range just because it usually does. None of this is exotic defensive programming — it's the ordinary discipline of not letting a remote party's response, well-intentioned or not, dictate your app's internal state or crash it outright.

Code Examples

This is the canonical shape of every NSJSONSerialization decode call: an NSError pointer initialized to nil, passed by address via &error, and the untyped id return value. The next step any real code must take — not shown here, but essential — is checking error for nil and then checking object's actual class before treating it as a dictionary or array.

id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];

This example chains together the three checks this lesson insists on: error must be nil, the top-level object must actually be the type the code expects (never force-cast), and any individual value must be checked against NSNull before being treated as real data, since a present-but-null JSON field decodes to NSNull rather than nil.

Common mistake: Writing NSDictionary *dict = object; without the isKindOfClass: check — this compiles fine and crashes at runtime the first time the server sends a top-level array instead.

userWithDictionary: is the single choke point where untrusted dictionary data becomes a strongly-typed User — every field is type-checked before being assigned, with a sensible fallback if the type doesn't match what was expected, and middleName is explicitly checked against NSNull. toDictionary does the reverse for encoding, substituting NSNull (never nil, which NSDictionary literals cannot hold) when middleName is absent. Writing this mapping once, in the model class itself, means the rest of the app works with typed User properties and never touches a raw dictionary key directly.

user.middleName = (middleName && ![middleName isKindOfClass:[NSNull class]]) ? middleName : nil;

NSError *error = nil;

id object = [NSJSONSerialization JSONObjectWithData:data
                                             options:0
                                               error:&error];
NSError *error = nil;
id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];

if (error) {
    NSLog(@"JSON parsing failed: %@", error.localizedDescription);
    return;
}

if (![object isKindOfClass:[NSDictionary class]]) {
    NSLog(@"Expected a JSON object at the top level, got %@", [object class]);
    return;
}

NSDictionary *dict = (NSDictionary *)object;
id middleName = dict[@"middleName"];
if (middleName && ![middleName isKindOfClass:[NSNull class]]) {
    NSLog(@"Middle name: %@", middleName);
} else {
    NSLog(@"No middle name provided");
}
@interface User : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@property (nonatomic, copy, nullable) NSString *middleName;
+ (instancetype)userWithDictionary:(NSDictionary *)dict;
- (NSDictionary *)toDictionary;
@end

@implementation User

+ (instancetype)userWithDictionary:(NSDictionary *)dict {
    User *user = [[User alloc] init];

    id name = dict[@"name"];
    user.name = [name isKindOfClass:[NSString class]] ? name : @"";

    id age = dict[@"age"];
    user.age = [age isKindOfClass:[NSNumber class]] ? [age integerValue] : 0;

    id middleName = dict[@"middleName"];
    user.middleName = (middleName && ![middleName isKindOfClass:[NSNull class]]) ? middleName : nil;

    return user;
}

- (NSDictionary *)toDictionary {
    return @{
        @"name": self.name ?: @"",
        @"age": @(self.age),
        @"middleName": self.middleName ?: [NSNull null]
    };
}

@end

Why This Matters in Production Apps

Nearly every crash report from a shipping Objective-C app that touches a network API traces back to one of the patterns in this lesson: a force-cast of the JSON top level, an unchecked NSNull, or a missing field the code assumed would always be there. Concentrating the type-checking and NSNull handling into one model-mapping method, as shown in this lesson's third example, is the standard, durable fix — it turns a class of runtime crashes into a single well-tested chokepoint instead of scattered, repeated dictionary access throughout the app.

NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];

id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; if ([object isKindOfClass:[NSDictionary class]]) { NSDictionary *dict = object; /* ... */ }

Force-typing the result as NSDictionary compiles without warning but crashes at runtime the moment the JSON's top level is actually an array — the method's return type is deliberately id because it cannot know in advance which one it will be.

if (dict[@"middleName"]) { NSString *m = dict[@"middleName"]; } id m = dict[@"middleName"]; if (m && ![m isKindOfClass:[NSNull class]]) { NSString *middleName = m; }

A key whose JSON value is explicitly null decodes to NSNull, which is truthy in an if check — the naive version treats a present-but-null field as present-and-usable and crashes or misbehaves when it's used as a string.

// ignoring the error parameter entirely because object looked non-nil if (error) { /* handle parse failure */ return; } // only then trust object

A parsing failure is reported through the error out-parameter; skipping that check and only eyeballing whether object is non-nil misses legitimate failure cases and can let a corrupted or truncated response silently propagate.

NSData *json = [NSJSONSerialization dataWithJSONObject:someModelObject options:0 error:&error];

NSData *json = [NSJSONSerialization dataWithJSONObject:[someModelObject toDictionary] options:0 error:&error];

dataWithJSONObject:options:error: only accepts JSON-representable Foundation types (dictionaries, arrays, strings, numbers, NSNull) — handing it a custom model object directly fails; the model must first convert itself to a plain dictionary.

What does NSJSONSerialization's JSONObjectWithData:options:error: return when the top-level JSON value is an array rather than an object? A. It throws an exception B. It returns nil with an error set C. It returns an NSArray, since the method's return type is id and it adapts to whichever JSON type is at the top level D. It wraps the array in a single-key NSDictionary automatically

C. It returns an NSArray, since the method's return type is id and it adapts to whichever JSON type is at the top level — The method returns id precisely because the top level of a JSON document may be either an object or an array; force-casting the result to NSDictionary without checking will crash whenever the payload is actually an array.

A JSON field is present with the value null. What does it decode to in the resulting Foundation object graph? A. Objective-C nil B. An empty NSString C. [NSNull null], a real singleton object D. The key is simply omitted from the dictionary

C. [NSNull null], a real singleton object — Foundation collections cannot store nil as a value, so NSJSONSerialization represents JSON null with the NSNull singleton instead — code must explicitly check for NSNull, since a naive nil-check treats it as present.

Why must a model class typically implement its own toDictionary-style method before calling dataWithJSONObject:options:error: on it? A. Because dataWithJSONObject: only accepts JSON-representable Foundation types (dictionaries, arrays, strings, numbers, NSNull), not arbitrary custom objects B. Because JSON encoding requires the object to conform to NSCoding C. Because dataWithJSONObject: is deprecated in favor of toDictionary D. Because Objective-C objects cannot contain NSNumber properties

A. Because dataWithJSONObject: only accepts JSON-representable Foundation types (dictionaries, arrays, strings, numbers, NSNull), not arbitrary custom objects — NSJSONSerialization's encoder only knows how to serialize the standard JSON-representable Foundation types; a custom model object must first be converted into a plain dictionary built from those types.

How does Swift's Codable fundamentally differ from Objective-C's typical JSON-handling approach? A. Codable requires a network connection to work, while Objective-C's approach does not B. Codable is compiler-generated and type-safe from the type's own declarations, while Objective-C's approach is written by hand and relies on stringly-typed dictionary key lookups C. Codable only works with NSDictionary, while Objective-C's approach only works with NSArray D. There is no real difference; both approaches are functionally identical

B. Codable is compiler-generated and type-safe from the type's own declarations, while Objective-C's approach is written by hand and relies on stringly-typed dictionary key lookups — Codable's encode/decode logic is generated by the Swift compiler and checked at compile time, whereas Objective-C developers write the dictionary-key mapping by hand, which puts key-name typos and type mismatches only at risk of a runtime bug.

Why should JSON received from a network response be treated as untrusted input, even from a server you trust? A. Because HTTPS does not protect JSON payloads specifically B. Because a well-behaved server can still send malformed data during an outage or schema change, and code should validate shape and types rather than assume them C. Because NSJSONSerialization cannot decode data larger than a few kilobytes D. Because JSON numbers are always sent as strings

B. Because a well-behaved server can still send malformed data during an outage or schema change, and code should validate shape and types rather than assume them — Even a generally reliable server can send unexpected shapes during outages, bugs, or backend schema changes, so defensive checks (error, top-level type, NSNull, missing keys) protect the app regardless of how trustworthy the source usually is.

Why must the local NSError *error variable be initialized to nil before being passed to JSONObjectWithData:options:error:? A. Because Objective-C requires all pointers to be initialized before use, with no exceptions B. Because if the call succeeds, it may leave the error pointer untouched, and an uninitialized local could contain garbage that looks like a misleading error object C. Because nil is a required sentinel value the method searches for before running D. It doesn't need to be initialized; it is purely a stylistic convention

B. Because if the call succeeds, it may leave the error pointer untouched, and an uninitialized local could contain garbage that looks like a misleading error object — An out-parameter method like this one typically only writes to the error pointer on failure; starting from a known nil value ensures that checking error afterward reliably reflects whether the call actually failed.

Given an NSData containing a JSON array of user objects (each with 'name', 'age', and an optional 'middleName' that may be missing or explicitly null), write an Objective-C method that safely decodes the data, verifies the top-level type is an array, and maps each element into a User model object using the userWithDictionary: pattern from this lesson, skipping any element that isn't itself a dictionary.

NSError *error = nil; id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; if (error || ![object isKindOfClass:[NSArray class]]) { return @[]; } NSMutableArray<User *> *users = [NSMutableArray array]; for (id element in (NSArray *)object) { if ([element isKindOfClass:[NSDictionary class]]) { [users addObject:[User userWithDictionary:element]]; } } return [users copy];

Check error first, then isKindOfClass:[NSArray class] on the decoded object, then iterate and check isKindOfClass:[NSDictionary class] on each element before calling userWithDictionary:.