Lightweight Generics: Typed Collections, Compile-Time Checking, and Runtime Erasure
Understand Objective-C's lightweight generics syntax for typed collections such as NSArray<NSString *> * and NSDictionary<NSString *, NSNumber *> *, including compile-time checking, covariance, the limits of the system, and its erasure at runtime.
Before lightweight generics, an NSArray property told you nothing about what it contained — NSArray *names could hold strings, numbers, or a mix of unrelated objects, and the compiler had no way to catch a mistake like inserting an NSNumber where the rest of your code assumed only NSString instances. Lightweight generics, added to Objective-C to pair naturally with Swift's own generic collections, let you write NSArray<NSString *> * and get real compile-time type checking on what goes into and comes out of that array. This lesson covers the syntax, what the compiler actually checks, the specific rules around covariance, where the system's limitations lie, and — critically — the runtime reality that these type parameters vanish entirely once your code is actually running.
The syntax: parameterizing NSArray and NSDictionary
Lightweight generics attach a type parameter, or a pair of them, inside angle brackets directly after the collection class name. NSArray<NSString *> * declares an array that is meant to hold only NSString instances, exactly as the spec's example shows. NSDictionary<NSString *, NSNumber *> * declares a dictionary whose keys are meant to be NSString and whose values NSNumber, again matching the spec's example precisely — the first type parameter is always the key type, the second the value type.
The same pattern extends to every other generic-capable Foundation collection: NSMutableArray<T *> *, NSSet<T *> *, NSMutableSet<T *> *, NSOrderedSet<T *> *, and NSMutableDictionary<K *, V *> * all accept type parameters the same way. You write the type parameter exactly where you'd otherwise write the pointer type of an element, complete with its own trailing asterisk, because the parameter is itself a pointer type like any other Objective-C object type.
This is "lightweight" specifically because it is sugar layered onto Foundation's existing, non-generic collection classes — NSArray itself did not change its underlying implementation. The generics exist purely to give the compiler extra type information to check against; nothing about how the array actually stores its elements is different from an unparameterized NSArray *.
Compile-time checking: what the compiler actually catches
Once you declare a variable, property, or return type as NSArray<NSString *> *, the compiler checks every operation against that constraint. Calling addObject: with an NSNumber on an NSMutableArray<NSString *> * produces a compiler warning, because you are inserting a type the array was declared not to hold. Assigning the result of firstObject or objectAtIndex: to a variable typed as anything other than NSString * (without an explicit cast) similarly produces a warning, because the compiler now knows what type to expect out of the collection and checks your usage of that value against it.
This closes a real and common category of bug. Before lightweight generics, a method returning NSArray * gave you no compiler help distinguishing 'array of Users' from 'array of Strings' from 'array of anything' — you relied entirely on documentation, naming conventions, and careful reading. With NSArray<User *> * as the declared return type, every caller gets the same type safety they'd expect from a typed collection in almost any other modern language, checked automatically wherever that array is used.
It's worth being precise about what "checking" means here: these are compiler warnings, not compiler errors, and not runtime checks. Ignoring the warning (or building with warnings not treated as errors) still compiles and runs. The safety this system provides is opt-in in the sense that you have to actually look at and heed your build warnings — but for a team that does, it catches real mistakes before they ship.
Covariance: safe for immutable collections, unsafe for mutable ones
Lightweight generics support covariance for immutable collections: an NSArray<NSString *> * can be assigned to a variable typed NSArray<id> *, or passed to a parameter expecting NSArray<id> *, without a cast or a warning. Intuitively this makes sense — anywhere code expects 'an array of some object type,' an array that happens to hold specifically NSString objects satisfies that expectation, because every NSString is an id.
This covariance is safe specifically because NSArray is immutable: once you have an NSArray<NSString *> * reference, nothing can be inserted into it through that reference (or any reference) after creation. Viewing it through a less specific NSArray<id> * type doesn't create any opportunity to violate the original NSString-only constraint, because there is no insertion operation to exploit — you can only read elements out, and every element really is a string.
The same covariance is deliberately unsafe, and Objective-C's type checker does not permit it, for mutable collections. If NSMutableArray<NSString *> * could be freely assigned to an NSMutableArray<id> *-typed variable, code holding that less-specific reference could legally call addObject: with an NSNumber, an NSDate, anything conforming to id — silently violating the original array's NSString-only contract through a different, more permissive-looking reference to the very same object. Because a mutable array's contents can change after the fact, covariance there would let the type system be defeated by simply viewing the same object through a broader type. This asymmetry — safe covariance for immutable collections, disallowed covariance for mutable ones — mirrors exactly the same variance rules found in strongly generic type systems like Java's and Swift's, for exactly the same underlying reason.
Generic collections in everyday API design
In practice, lightweight generics are most valuable exactly where types cross API boundaries: property declarations, method parameters, and method return types. Declaring @property (nonatomic, copy) NSArray<Order *> *pendingOrders; on a class tells every reader of the header, and the compiler enforcing every caller, exactly what that property holds, with none of the ambiguity a bare NSArray * would leave.
The same applies to method signatures: - (void)processUsers:(NSArray<User *> *)users; documents the expected element type directly in the signature rather than in a comment that can drift out of date, and - (nullable NSDictionary<NSString *, NSNumber *> *)inventoryCountsByProductID; tells callers immediately what shape of dictionary to expect back, combining naturally with the nullability annotations from the previous lesson.
This combination — nullability plus lightweight generics — is exactly the kind of Objective-C that reads cleanly to Swift, since Swift's own collection types are natively generic. An NSArray<NSString *> * with appropriate nullability imports into Swift as a properly typed [String] or [String]?, rather than the untyped, unsafe [Any] a bare NSArray * would produce.
The limitation: no user-defined generic classes
It is essential to understand what lightweight generics do not give you: the ability to define your own generic classes the way Swift (struct Stack<Element>) or Java (class Box<T>) allow. There is no Objective-C syntax for declaring @interface Stack<__covariant ObjectType> : NSObject with a general-purpose type parameter usable freely throughout a class's own implementation the way Swift generics are.
What Objective-C does support is a narrow, purpose-built variant of that syntax used specifically for declaring the generic parameters on Foundation's own collection classes and, in limited form, on your own subclasses of them — for instance a custom NSArray subclass can declare its own element type parameter in the same style. But this is fundamentally collection-oriented sugar layered on top of existing container classes, not a general-purpose generics system you can use to write, say, a generic binary tree, a generic result-or-error wrapper, or any arbitrary generic data structure of your own design the way you could in Swift or Java.
This limitation is precisely why Objective-C developers who need real generic data structures typically write them in Swift and expose an Objective-C-compatible, non-generic interface at the boundary, rather than attempting to force Objective-C's lightweight system to do something it was never designed for.
The runtime reality: generics are erased
This is the point the spec singles out as important, and it is genuinely easy to get wrong if you only ever think about generics at the source level: lightweight generics exist purely at compile time and are completely erased by the time your code runs. At runtime, an NSArray<NSString *> * is, in every observable way, an ordinary NSArray — the same class, the same instance, with no attached metadata recording that it was ever declared to hold only strings.
Concretely, this means [array isKindOfClass:[NSArray class]] behaves identically whether array was declared NSArray * or NSArray<NSString *> *, and there is no runtime API — not NSInvocation, not the Objective-C runtime's introspection functions, not KVC, not bridging into Objective-C++ — that can recover or enforce the original element-type constraint. Code that reaches an array through any of these lower-level, less-type-checked paths, or through a plain NSArray * variable that simply never went through a spot the compiler could check, can insert an object of the wrong type without any warning or error, at compile time or run time.
The practical upshot is that lightweight generics are a documentation and static-analysis tool, not a safety net you can rely on unconditionally. They dramatically improve the experience of writing and reading well-typed Objective-C, and they meaningfully improve Swift interoperability, but they do not provide the kind of runtime guarantee that, say, Java's array store checks or a truly reified generic system would. Treat a generically-typed collection as 'the compiler will catch most mistakes for code that goes through normal typed access,' not as 'this array is physically incapable of holding the wrong type.'
Generic collection type parameters
NSArray takes a single type parameter: the element type. NSDictionary takes two: key type, then value type, in that order. Type parameters are ordinary Objective-C pointer types, including your own custom classes.
NSArray<NSString *> *names;
NSDictionary<NSString *, NSNumber *> *scoresByName;
NSMutableArray<Order *> *pendingOrders;Code Examples
names is declared as an array specifically of NSString objects, and scores as a dictionary mapping NSString keys to NSNumber values — exactly the syntax the spec calls out. The compiler now checks every subsequent use of names and scores against these declared element types.
Because tags is typed NSMutableArray<NSString *> *, the compiler flags inserting an NSNumber and flags assigning firstObject's result to an NSNumber * variable. Neither line fails to compile outright by default, but both produce warnings that a diligent build catches before the mistake reaches production.
Assigning the immutable strings to the less-specific NSArray<id> * is permitted because nothing can be inserted through either reference. Doing the same for the mutable array is flagged, because anyMutArray could otherwise be used to insert a non-string object into the very same underlying array that mutStrings still expects to hold only strings.
NSArray<NSString *> *names = @[@"Ada", @"Grace", @"Margaret"];
NSDictionary<NSString *, NSNumber *> *scores = @{@"Ada": @95, @"Grace": @98};NSMutableArray<NSString *> *tags = [NSMutableArray array];
[tags addObject:@"objc"]; // fine
[tags addObject:@42]; // compiler warning: NSNumber vs expected NSString *
NSString *first = tags.firstObject; // fine, compiler knows this is NSString *
NSNumber *bad = tags.firstObject; // compiler warning: type mismatchNSArray<NSString *> *strings = @[@"a", @"b"];
NSArray<id> *anyArray = strings; // OK: safe, immutable covariance
NSMutableArray<NSString *> *mutStrings = [NSMutableArray arrayWithObject:@"a"];
NSMutableArray<id> *anyMutArray = mutStrings; // compiler warning: unsafe for mutable arraysGenerics as an incremental adoption, not a rewrite
Lightweight generics were designed to be adopted incrementally into existing Objective-C codebases without breaking anything, which is exactly how most teams use them in practice: adding <NSString *> and similar parameters to existing NSArray * properties and method signatures one header at a time, watching the compiler surface previously invisible mismatches, and fixing them as they go. Because the change is purely additive at the type level — no runtime behavior changes, no new classes are introduced — it's a low-risk way to progressively harden a large legacy codebase's type safety and dramatically improve how cleanly its headers import into Swift, without a rewrite.
// assuming an NSArray<NSString *> * physically rejects a wrong-typed object inserted via KVC or NSInvocation at runtime
// treating the generic parameter as compile-time documentation only, and validating untrusted input defensively at runtime if it matters
Lightweight generics are fully erased at runtime. There is no NSArray subclass, no hidden metadata, and no runtime check that stops the wrong type from being inserted through a path the compiler didn't see — NSInvocation, KVC, or bridging code can all bypass it silently.
@interface Stack<ObjectType> : NSObject // attempting to freely use ObjectType as a general-purpose generic parameter @end
// use Objective-C's lightweight generics only on collection-style classes, or write truly generic data structures in Swift
Lightweight generics are a narrow, collection-focused feature, not a general-purpose generics system. Objective-C does not let you define arbitrary generic classes the way Swift or Java do; the angle-bracket syntax is purpose-built for Foundation's collection classes and their subclasses.
NSMutableArray<NSString *> *strings = ...; NSMutableArray<id> *anyArray = strings; // ignoring the compiler warning [anyArray addObject:@42]; // strings now silently contains an NSNumber
// keep mutable generic-typed references at their declared specificity, or copy/cast deliberately with awareness of the risk
Covariance is unsafe for mutable collections precisely because it lets code insert the wrong type through a broader reference to the same underlying mutable object. The compiler warns about this exact assignment; ignoring the warning defeats the type system entirely.
What does NSDictionary<NSString *, NSNumber *> * declare? A. A dictionary that can hold either NSString or NSNumber values B. A dictionary whose keys are NSString and whose values are NSNumber C. A dictionary limited to exactly one NSString key mapped to one NSNumber value D. A dictionary whose keys are NSNumber and whose values are NSString
B. A dictionary whose keys are NSString and whose values are NSNumber — For NSDictionary, the first type parameter is always the key type and the second is the value type, so NSDictionary<NSString *, NSNumber *> * means string keys mapped to number values.
Why is it safe to assign an NSArray<NSString *> * to an NSArray<id> *-typed variable, but not safe to do the same with NSMutableArray? A. It is never actually safe for NSArray either — the compiler simply doesn't check it B. NSArray is immutable, so there's no way to insert a wrong-typed object through the broader reference; NSMutableArray could have one inserted through it C. NSMutableArray doesn't support generics at all D. id types cannot be used with NSArray, only with NSMutableArray
B. NSArray is immutable, so there's no way to insert a wrong-typed object through the broader reference; NSMutableArray could have one inserted through it — Covariance is safe for immutable collections because nothing can be inserted after creation, so viewing them through a broader type can't violate the original element-type constraint. Mutable collections can have new objects inserted through the broader reference, which could violate the constraint, so the compiler disallows it.
What is a genuine limitation of Objective-C's lightweight generics? A. They only work with NSArray, not NSDictionary B. You cannot define your own general-purpose generic classes the way Swift or Java allow — the system is purpose-built for collection classes C. They require ARC to be disabled D. They cannot be combined with nullability annotations
B. You cannot define your own general-purpose generic classes the way Swift or Java allow — the system is purpose-built for collection classes — Lightweight generics are collection-focused sugar, not a general generics system. Objective-C has no syntax for arbitrary user-defined generic classes with freely usable type parameters the way Swift or Java do.
What happens to lightweight generic type parameters at runtime? A. They are preserved and enforced by the Objective-C runtime on every insertion B. They are completely erased — an NSArray<NSString *> * is an ordinary NSArray with no runtime record of its element type C. They are converted into a Swift-only runtime representation D. They cause a runtime exception if violated
B. They are completely erased — an NSArray<NSString *> * is an ordinary NSArray with no runtime record of its element type — Lightweight generics exist only at compile time. At runtime there is no metadata distinguishing an NSArray<NSString *> * from a plain NSArray *, and nothing stops a wrong-typed object from being inserted through a path the compiler didn't check, such as NSInvocation or KVC.
Which of these is a correct, idiomatic use of lightweight generics in an API declaration? A. - (void)processUsers:(NSArray<User *> *)users; B. - (void)processUsers:(NSArray<*User> *)users; C. - (void)processUsers:(NSArray[User] *)users; D. - (void)processUsers:(NSArray<User> *)users;
A. - (void)processUsers:(NSArray<User *> *)users; — The type parameter is written as an ordinary pointer type inside angle brackets, including its own asterisk: NSArray<User *> *. The other forms use invalid syntax (missing the parameter's own asterisk, square brackets, or a misplaced asterisk).
Declare a method - (NSDictionary<NSString *, NSArray<NSString *> *> *)tagsGroupedByCategory that returns a dictionary mapping category name strings to arrays of tag name strings. Then explain, in one or two sentences, why an NSMutableDictionary<NSString *, NSArray<NSString *> *> * built from this data could not safely be treated as NSMutableDictionary<NSString *, NSArray<id> *> * without a compiler warning.
• (NSDictionary<NSString *, NSArray<NSString *> *> *)tagsGroupedByCategory; // A mutable dictionary cannot safely be viewed with a broader value type because code holding the broader-typed reference could call setObject:forKey: with a value array of the wrong element type, corrupting the original dictionary's declared contract — the same unsafe-covariance issue as with mutable arrays.
Nesting generic collections inside each other is legal — the value type parameter can itself be a generic collection type.