NSCoding and NSSecureCoding

Implement NSCoding to make a custom class archivable with NSKeyedArchiver/NSKeyedUnarchiver, upgrade an implementation to NSSecureCoding to safely decode untrusted data, and handle class versioning so archives created by an older app version still load correctly after new fields are added.

Persistence is the general problem of getting an object's state to outlive the process that created it — written to disk, sent over a network, or stored in NSUserDefaults. Objective-C's Foundation-level mechanism for turning an arbitrary object graph into a stream of bytes (and back) is NSCoding, driven by NSKeyedArchiver and NSKeyedUnarchiver. This lesson covers the full round trip: writing a class that conforms to NSCoding, archiving and unarchiving it, evolving it safely over time as your app's model changes, and — critically — why plain NSCoding is not safe to use on data you did not create yourself, and what NSSecureCoding does about that. This connects directly to the 'Persistence', 'Data and File System', 'App Security', and 'Security Checklist' modules: NSCoding is the mechanism, but the security implications of decoding untrusted archived data belong squarely in that security-minded part of the course too.

The Archiving Pipeline, End to End

Archiving an object graph is a round trip through several distinct stages, and it helps to see the whole shape before looking at any code. An object you want to persist implements encodeWithCoder:, which NSKeyedArchiver calls to ask the object to describe its own state as a series of key-value pairs. The archiver assembles everything encoded this way — walking the object graph, encoding nested objects too — into a single archive, a self-contained blob of data suitable for writing to disk or sending over a network. Later, that archive is read back: NSKeyedUnarchiver walks the archive and, for each encoded object, allocates a new instance and calls initWithCoder:, which is where the object reconstructs itself from the key-value pairs it originally wrote out — ending back where it started, a live object graph in memory.

It is worth naming precisely why this exists as a distinct mechanism rather than, say, just writing ivars to a file by hand: objects are not flat, contiguous blocks of memory you can safely dump byte-for-byte — they contain pointers to other objects, some of which are shared, some of which form cycles, and a naive raw memory dump would be meaningless once read back into a different run of the program (or a different machine) where those pointer addresses no longer mean anything. NSKeyedArchiver instead encodes a *graph* of key-value data, tracking object identity so shared references and cycles are preserved correctly across the round trip, and reconstructs real, fully-initialized objects on the way back in — not raw memory, but a faithful, self-describing recreation of the original object graph.

• encodeWithCoder: — an object's chance to describe its own state as key-value pairs when the archiver visits it. • NSKeyedArchiver — walks an object graph and assembles everything into a single archive (byte stream). • NSKeyedUnarchiver — reads an archive back and, for each object, allocates it and calls initWithCoder: to let it reconstruct itself. • initWithCoder: — the counterpart to encodeWithCoder:, where an object rebuilds its own state from decoded key-value pairs. • This is a graph-aware mechanism, not a raw memory dump — it correctly preserves shared references and cycles across the round trip.

The Archiving Pipeline, Visually

The diagram below traces one object through the full round trip, from a live object in memory, through encoding, into storage or across a transfer, and back again into a live object. Every arrow corresponds to a concrete method call or a concrete storage/transfer step described in this lesson — this is not a metaphor, it is literally the sequence NSKeyedArchiver and NSKeyedUnarchiver perform.

A Complete NSCoding Implementation

Adopting NSCoding means declaring conformance (@interface Contact : NSObject <NSCoding>) and implementing exactly two methods: encodeWithCoder: and initWithCoder:. Inside encodeWithCoder:, each piece of state you want persisted is written out with encodeObject:forKey: (for object-type ivars — strings, arrays, dictionaries, nested NSCoding-conforming custom objects) or one of the primitive-specific variants like encodeInteger:forKey:, encodeBool:forKey:, or encodeDouble:forKey: (for C primitives, which are not objects and so cannot go through encodeObject:forKey:). Every key you choose is an arbitrary string constant you pick yourself — by convention, defined once as a static NSString * const so encode and decode sides can never accidentally drift apart from a typo.

Inside initWithCoder:, you first call [super initWithCoder:aDecoder] if the superclass also conforms to NSCoding (mirroring the normal [super init] pattern for any initializer), then read each key back out with the matching decode method — decodeObjectForKey: for objects, decodeIntegerForKey:, decodeBoolForKey:, decodeDoubleForKey: for the corresponding primitives — using the exact same key strings used during encoding. Every encode call in encodeWithCoder: should have a matching decode call in initWithCoder:, for the same key, or that piece of state is silently lost on the round trip — a mismatch here does not produce a compiler error, only a field that comes back empty or with a default value, which makes careful, matching key constants an important habit rather than a nicety.

decodeObjectForKey: deserves a specific callout: it is typed to return id and performs no check whatsoever on what class of object was actually archived at that key — it will happily hand you back an object of any class the archive happened to contain there, and it is entirely the caller's responsibility to trust that it is the type it expects. That looseness is exactly what makes plain NSCoding unsafe for untrusted data, and is the problem the next section's NSSecureCoding solves directly.

• Adopt with <NSCoding>; implement encodeWithCoder: and initWithCoder: — both are required for the protocol to actually work. • encodeObject:forKey: for object-type ivars; encodeInteger:/encodeBool:/encodeDouble:forKey: (etc.) for C primitives. • initWithCoder: mirrors this with decodeObjectForKey: and the matching primitive decode methods, using identical key strings. • Define keys once as static NSString * const constants shared between encode and decode, to prevent typo-driven mismatches. • decodeObjectForKey: returns untyped id with no class validation — it trusts the archive completely, which is the root of the security concern covered next.

Why decodeObjectForKey: Is Dangerous for Untrusted Data

The risk with plain NSCoding is concrete, not theoretical: NSKeyedArchiver's archive format encodes not just the data for each object but also which class each object should be instantiated as when decoded. When you call decodeObjectForKey: on data that came from a source you do not fully trust — a file downloaded from the network, a document opened from outside your app's sandbox, data received over inter-process communication — nothing stops that archive from claiming an object at some key is an instance of an entirely different class than the one your code expects, as long as that class also happens to conform to NSCoding and is present in your app's runtime. An attacker who can influence archived bytes your app decodes can potentially cause your app to instantiate a class you never intended, feeding it attacker-controlled state through that class's own initWithCoder: — in the worst case this is a genuine, exploitable memory-safety and code-execution risk, not just a type-confusion inconvenience.

NSSecureCoding closes this hole by making class validation part of decoding itself, rather than leaving it entirely to trust. A class opts in by implementing a class method, + (BOOL)supportsSecureCoding { return YES; }, declaring that its own initWithCoder: is safe to call under secure decoding. On the decode side, instead of the untyped decodeObjectForKey:, you call decodeObjectOfClass:forKey: (or decodeObjectOfClasses:forKey: when a key might legitimately hold one of several allowed classes, such as any object in a heterogeneous array), explicitly passing the Class (or set of classes) you expect at that key. NSKeyedUnarchiver checks the class actually encoded at that key against the class(es) you named, and refuses to instantiate anything else — an archive that claims a different, unexpected class at that key fails to decode that value rather than silently instantiating whatever it says.

This is exactly why NSSecureCoding is described as an 'allowed classes' mechanism: rather than trusting the archive's self-reported type information unconditionally, decoding is validated against an explicit whitelist you provide at each decode call, so an attacker cannot smuggle in an unexpected class merely by claiming it in the archive's metadata. To use secure decoding end to end, you also initialize the unarchiver itself in its secure-decoding configuration (setting requiresSecureCoding = YES on an NSKeyedUnarchiver instance, or using the throwing convenience API designed for secure decoding), which enforces that every class touched during that decode operation, all the way down the object graph, actually declares supportsSecureCoding — a class that hasn't opted in cannot be silently decoded through a supposedly secure unarchiver at all.

• Plain NSCoding archives encode which class to instantiate for each object, and decodeObjectForKey: trusts that claim completely. • Decoding untrusted data (from disk, network, IPC) with plain NSCoding can let an attacker cause instantiation of an unexpected class with attacker-controlled state — a real, exploitable risk, not a hypothetical one. • NSSecureCoding: a class opts in with + (BOOL)supportsSecureCoding { return YES; }. • decodeObjectOfClass:forKey: (or decodeObjectOfClasses:forKey:) explicitly whitelists which class(es) are acceptable at that key, and the unarchiver enforces it, rejecting anything else. • requiresSecureCoding = YES on the unarchiver enforces that every class in the decoded graph has opted into supportsSecureCoding, not just the top-level object.

Class Versioning and Migration

Real apps change their model classes over time, and archives written by an older version of the app must still decode correctly after the app is updated — you cannot assume every archive on a user's disk matches your current model's exact set of fields. The standard, low-ceremony technique for this is defensive decoding: before reading a key that might not exist in an older archive, check for its presence with [aDecoder containsValueForKey:@"someNewKey"], and fall back to a sensible default when it is absent, rather than unconditionally decoding a key that simply was never written by an older app version.

This matters because decoding a missing key with decodeObjectForKey: does not throw or crash — it simply returns nil (or 0/NO/0.0 for the primitive-typed decode methods) — which can silently mask the difference between 'this field was genuinely nil when archived' and 'this field did not exist yet in the version of the app that wrote this archive.' Using containsValueForKey: first lets initWithCoder: distinguish those cases deliberately and apply a real default (like a newly-added NSInteger schemaVersion defaulting to 1 for pre-versioning archives, or a newly-added BOOL isFavorite defaulting to NO) instead of accidentally leaving a new field silently zeroed with no clear signal that a migration decision was even needed.

For larger, structural changes — not just one new field, but a reshaped class — it is common practice to encode an explicit version number as its own key (encodeInteger:forKey:@"modelVersion") so initWithCoder: can branch on it explicitly: decode one way for archives written before some change, another way after, keeping backward compatibility intentional and centralized in one place rather than scattered containsValueForKey: checks that become hard to reason about as a class accumulates more and more migrations over several app versions.

• Archives written by an older app version must still decode correctly after the app updates — don't assume every archive matches the current model exactly. • [aDecoder containsValueForKey:@"key"] checks whether a key exists before decoding it, letting initWithCoder: supply a real default for fields added later. • Decoding a missing key returns nil/0/NO/0.0 rather than crashing — which can silently hide a real migration need unless you check for presence explicitly. • For structural changes, encoding an explicit version number lets initWithCoder: branch cleanly between old-format and new-format decoding logic. • Centralizing versioning logic in initWithCoder: keeps migration intentional and readable as a class accumulates changes over multiple app releases.

Cross-References: Where NSCoding Fits in the Bigger Picture

NSCoding is one layer in a larger persistence story covered by the dedicated 'Persistence' and 'Data and File System' modules: those modules cover where archived data actually lives (NSUserDefaults, a file in the app's Documents directory, a database), while this lesson covers the format-and-object-graph mechanism used to turn objects into bytes in the first place, independent of where those bytes ultimately end up. NSCoding-based archiving is one of several persistence techniques you'll see across the course, alongside plain property-list serialization and Core Data, and knowing which to reach for depends on the shape and trust level of the data involved.

The security dimension of this lesson — why decodeObjectForKey: is dangerous on untrusted input, and why NSSecureCoding's allowed-classes validation matters — is a specific instance of a general theme covered more broadly in the 'App Security' and 'Security Checklist' modules: never trust the structure or type of data that crosses a trust boundary (disk, network, inter-process communication) without validating it, and prefer APIs that make unsafe assumptions explicit and checkable rather than implicit and silently trusted. Any time your app decodes an NSKeyedArchiver archive that did not originate entirely from your own app's own prior encoding, in the same trust domain, reach for NSSecureCoding rather than plain NSCoding as the default, safer choice.

• 'Persistence' / 'Data and File System' modules: where archived bytes are stored (files, NSUserDefaults, databases). • This lesson: the object-graph-to-bytes mechanism itself (NSCoding, NSKeyedArchiver/NSKeyedUnarchiver), independent of storage location. • 'App Security' / 'Security Checklist' modules: the general principle of never trusting data across a trust boundary without validation — NSSecureCoding is one concrete application of that principle. • Default to NSSecureCoding, not plain NSCoding, whenever decoding data that did not originate entirely from your own app's own prior encoding.

Code Examples

encodeWithCoder: writes three keyed values: two objects (name, phone) via encodeObject:forKey:, and one primitive (age, an NSInteger) via encodeInteger:forKey:. initWithCoder: reads them back with the matching decode methods and the identical key constants, after calling [super init] as any initializer must. This is the minimum complete NSCoding implementation — every encode call has exactly one matching decode call for the same key.

[aCoder encodeObject:self.name forKey:kContactNameKey];

archivedDataWithRootObject:requiringSecureCoding:error: drives encodeWithCoder: on contact (and, transitively, on anything it references) to produce an NSData archive, which is then written to disk. unarchivedObjectOfClass:fromData:error: reads it back, drives initWithCoder:, and returns a fully reconstructed Contact. Passing NO for requiringSecureCoding here is intentionally shown as the less-safe path, for contrast with the secure version in the next example. Expected result: Priya Shah is 34

Two changes upgrade this class to NSSecureCoding: adopting <NSSecureCoding> (a subprotocol of NSCoding) instead of plain <NSCoding>, and implementing +supportsSecureCoding to return YES. Inside initWithCoder:, decodeObjectForKey: is replaced with decodeObjectOfClass:[NSString class] forKey:, explicitly whitelisting NSString as the only acceptable class for those two keys — if an attacker-crafted archive claimed some other class at kContactNameKey, decoding fails safely instead of instantiating it.

_name = [aDecoder decodeObjectOfClass:[NSString class] forKey:kContactNameKey];

Common mistake: Adopting NSSecureCoding but leaving decodeObjectForKey: unchanged — supportsSecureCoding alone does nothing without also switching to the class-checked decode methods.

unarchivedObjectOfClass:fromData:error: is itself a secure-decoding API: it requires the top-level class to match [Contact class] and, because Contact's initWithCoder: uses decodeObjectOfClass:forKey: throughout, every nested value is validated too. If untrustedData was tampered with to claim a different class anywhere in the graph, restored comes back nil with a populated error instead of an attacker-controlled object being silently instantiated.

[NSKeyedUnarchiver unarchivedObjectOfClass:[Contact class] fromData:untrustedData error:&error];

isFavorite is a field added in a later app version. containsValueForKey: checks whether an archive being decoded actually wrote that key before trying to read it — an archive created by the v2.0-or-later app has it and decodes it normally, while an older archive from before that field existed simply falls into the else branch and gets an explicit, intentional default of NO, rather than the ambiguous 'missing key silently decodes as NO anyway' behavior masking whether a real migration decision was made.

if ([aDecoder containsValueForKey:kContactIsFavoriteKey]) { ... } else { _isFavorite = NO; }

static NSString * const kContactNameKey  = @"name";
static NSString * const kContactPhoneKey = @"phone";
static NSString * const kContactAgeKey   = @"age";

@interface Contact : NSObject <NSCoding>
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *phone;
@property (nonatomic) NSInteger age;
@end

@implementation Contact

- (void)encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeObject:self.name forKey:kContactNameKey];
    [aCoder encodeObject:self.phone forKey:kContactPhoneKey];
    [aCoder encodeInteger:self.age forKey:kContactAgeKey];
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    if (self) {
        _name  = [aDecoder decodeObjectForKey:kContactNameKey];
        _phone = [aDecoder decodeObjectForKey:kContactPhoneKey];
        _age   = [aDecoder decodeIntegerForKey:kContactAgeKey];
    }
    return self;
}

@end
Contact *contact = [[Contact alloc] init];
contact.name = @"Priya Shah";
contact.phone = @"555-0142";
contact.age = 34;

NSError *archiveError = nil;
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:contact
                                      requiringSecureCoding:NO
                                                      error:&archiveError];
[data writeToFile:filePath atomically:YES];

NSData *loaded = [NSData dataWithContentsOfFile:filePath];
NSError *unarchiveError = nil;
Contact *restored = [NSKeyedUnarchiver unarchivedObjectOfClass:[Contact class]
                                                        fromData:loaded
                                                           error:&unarchiveError];
NSLog(@"%@ is %ld", restored.name, (long)restored.age);
@interface Contact : NSObject <NSSecureCoding>
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *phone;
@property (nonatomic) NSInteger age;
@end

@implementation Contact

+ (BOOL)supportsSecureCoding {
    return YES;
}

- (void)encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeObject:self.name forKey:kContactNameKey];
    [aCoder encodeObject:self.phone forKey:kContactPhoneKey];
    [aCoder encodeInteger:self.age forKey:kContactAgeKey];
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    if (self) {
        // decodeObjectOfClass:forKey: validates the archived class before
        // instantiating anything — an unexpected class fails to decode
        // instead of being silently instantiated.
        _name  = [aDecoder decodeObjectOfClass:[NSString class] forKey:kContactNameKey];
        _phone = [aDecoder decodeObjectOfClass:[NSString class] forKey:kContactPhoneKey];
        _age   = [aDecoder decodeIntegerForKey:kContactAgeKey];
    }
    return self;
}

@end
NSData *untrustedData = [NSData dataWithContentsOfURL:downloadedFileURL];

NSError *error = nil;
Contact *restored = [NSKeyedUnarchiver unarchivedObjectOfClass:[Contact class]
                                                        fromData:untrustedData
                                                           error:&error];
if (!restored || error) {
    NSLog(@"Refusing to trust malformed or unexpected archive: %@", error);
    return;
}
// Safe to use restored — its class, and the classes of every object
// it decoded via decodeObjectOfClass:forKey:, were validated.
static NSString * const kContactIsFavoriteKey = @"isFavorite"; // added in v2.0

- (void)encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeObject:self.name forKey:kContactNameKey];
    [aCoder encodeObject:self.phone forKey:kContactPhoneKey];
    [aCoder encodeInteger:self.age forKey:kContactAgeKey];
    [aCoder encodeBool:self.isFavorite forKey:kContactIsFavoriteKey];
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    if (self) {
        _name  = [aDecoder decodeObjectOfClass:[NSString class] forKey:kContactNameKey];
        _phone = [aDecoder decodeObjectOfClass:[NSString class] forKey:kContactPhoneKey];
        _age   = [aDecoder decodeIntegerForKey:kContactAgeKey];

        if ([aDecoder containsValueForKey:kContactIsFavoriteKey]) {
            _isFavorite = [aDecoder decodeBoolForKey:kContactIsFavoriteKey];
        } else {
            _isFavorite = NO; // archives from before v2.0 never wrote this key
        }
    }
    return self;
}

The Archiving Round Trip

encodeWithCoder: turns a live object into key-value data assembled into an Archive; that archive is written to Storage or sent across a Transfer (disk, network, IPC); Unarchive reads it back and drives initWithCoder: for each object, reconstructing the live Object graph. NSSecureCoding validates the classes involved at the Unarchive/initWithCoder: step, rather than trusting the archive's self-reported types unconditionally.

Object
  ↓
encodeWithCoder:
  ↓
Archive
  ↓
Storage / Transfer
  ↓
Unarchive
  ↓
initWithCoder:
  ↓
Object

Where This Shows Up in Real Apps

Any app that restores state from a file it wrote itself in a previous launch (saved documents, a cached response, drag-and-drop pasteboard data, a Handoff payload from another device) is doing exactly this round trip, and any of those sources can, in practice, be tampered with by something other than your own app's own prior encode — a shared file location, a jailbroken device, a maliciously crafted document opened via a file-sharing extension, or corrupted data from a flaky network transfer. This is precisely why Apple's own frameworks moved toward requiring NSSecureCoding by default in many contexts (state restoration, NSUserActivity, and various IPC-adjacent APIs increasingly refuse or warn on non-secure decoding) — it is not a rare edge case reserved for security specialists, it is the default, responsible way to decode anything that did not, with certainty, originate entirely from your own app's own prior encoding in the same trust domain.

_name = [aDecoder decodeObjectForKey:kContactNameKey]; // untyped, no class check

_name = [aDecoder decodeObjectOfClass:[NSString class] forKey:kContactNameKey];

decodeObjectForKey: trusts the archive's self-reported class completely and returns whatever class was actually encoded there, with no validation. When the archive's origin is not fully trusted, decodeObjectOfClass:forKey: (paired with +supportsSecureCoding returning YES and an unarchiver configured for secure decoding) enforces that the decoded object really is the expected class before handing it back, refusing to instantiate anything else.

In the archiving round trip, which method is responsible for turning a live object's state into key-value pairs before it becomes part of an archive? A. initWithCoder: B. encodeWithCoder: C. copyWithZone: D. decodeObjectForKey:

B. encodeWithCoder: — encodeWithCoder: is called by NSKeyedArchiver on each object it visits, and is where that object writes its own state out as key-value pairs to be assembled into the archive.

Concretely, what can go wrong when decodeObjectForKey: is used to decode data from an untrusted source, like a file downloaded from the network? A. Nothing — decodeObjectForKey: always validates the class before returning it B. The archive can claim a different, unexpected class than your code expects at that key, causing your app to instantiate attacker-controlled objects with attacker-controlled state C. It only affects performance, not correctness or safety D. It only fails if the file is corrupted, never for a maliciously crafted but well-formed archive

B. The archive can claim a different, unexpected class than your code expects at that key, causing your app to instantiate attacker-controlled objects with attacker-controlled state — decodeObjectForKey: trusts the archive's self-reported class completely. An attacker who can influence the archive's bytes can potentially cause a different class than expected to be instantiated with attacker-controlled data, which is a real exploitable risk, not a hypothetical one.

What two things does a class need in order to properly support NSSecureCoding? A. Only adopting the <NSSecureCoding> protocol in its @interface B. Implementing +(BOOL)supportsSecureCoding returning YES, AND using class-validated decode methods like decodeObjectOfClass:forKey: instead of decodeObjectForKey: C. Overriding allocWithZone: to reject unexpected classes D. Encrypting the archive data before writing it to disk

B. Implementing +(BOOL)supportsSecureCoding returning YES, AND using class-validated decode methods like decodeObjectOfClass:forKey: instead of decodeObjectForKey: — Adopting the protocol and returning YES from supportsSecureCoding declares intent, but the actual safety comes from switching every decode call in initWithCoder: to a class-checked variant like decodeObjectOfClass:forKey: — supportsSecureCoding alone changes nothing if decodeObjectForKey: is still used underneath.

An older archive, written before a new isFavorite field existed, is decoded by the updated app. Without a containsValueForKey: check, what happens when initWithCoder: calls decodeBoolForKey: for that key? A. The app crashes immediately B. It throws an NSInvalidArgumentException C. It silently returns NO, indistinguishable from an archive that explicitly encoded isFavorite as NO D. NSKeyedUnarchiver refuses to load the entire archive

C. It silently returns NO, indistinguishable from an archive that explicitly encoded isFavorite as NO — Decoding a missing key does not crash or throw — the primitive decode methods simply return their zero-equivalent default (NO/0/0.0). This can silently mask whether a real migration decision needed to be made, which is exactly why containsValueForKey: should be checked first when a field might not exist in older archives.

Why is NSCoding described in this lesson as connected to, but distinct from, the 'Persistence' and 'Data and File System' modules? A. They cover exactly the same material with no distinction B. NSCoding covers the object-graph-to-bytes encoding mechanism itself, while Persistence/Data and File System cover where those resulting bytes are actually stored (files, NSUserDefaults, databases) C. NSCoding replaces the need for a file system entirely D. NSSecureCoding is only relevant to network requests, never to file storage

B. NSCoding covers the object-graph-to-bytes encoding mechanism itself, while Persistence/Data and File System cover where those resulting bytes are actually stored (files, NSUserDefaults, databases) — NSCoding is the mechanism for turning an object graph into a self-describing stream of bytes and back; where those bytes are actually written or read from is a separate concern covered by the Persistence and Data and File System modules — the two are complementary, not the same topic.

Take the Contact class from this lesson and add a new NSArray<NSString *> *tags property, added in a hypothetical v3.0 of the app. Update encodeWithCoder: to encode it, and update initWithCoder: both to decode it securely (using decodeObjectOfClasses:forKey: since it must accept an NSArray containing NSString elements) and to default it to an empty array for archives written before v3.0 that never encoded this key.

// encode: [aCoder encodeObject:self.tags forKey:kContactTagsKey]; // decode: if ([aDecoder containsValueForKey:kContactTagsKey]) { NSSet *allowed = [NSSet setWithObjects:[NSArray class], [NSString class], nil]; _tags = [aDecoder decodeObjectOfClasses:allowed forKey:kContactTagsKey]; } else { _tags = @[]; } decodeObjectOfClasses:forKey: takes an NSSet of allowed classes rather than a single class, since the decoded value legitimately needs to be an NSArray whose elements are NSString — pass a set containing both [NSArray class] and [NSString class]. Wrap the decode in a containsValueForKey: check exactly like the isFavorite example.