Objective-C generics: Lightweight generics guide for iOS devs

Objective-C generics don't behave like C++ templates or Swift generics, and treating them that way is the single biggest source of confusion among senior iOS engineers adopting them. Lightweight generics are a compile-time annotation layer bolted onto an unchanged Objective-C runtime, they catch mismatched types in Xcode, then vanish before the app ever runs.

Understanding exactly where that boundary sits is what lets you use them to kill unsafe downcasting without misjudging what they actually protect against, especially once Swift interop enters the picture. That distinction matters even more if you're weighing Swift against Objective-C for a new project, since Swift's own generics system takes a fundamentally different approach under the hood.

Objective-C generics at a glance

Casting an NSArray element and hoping it's the right class is where most Objective-C bugs start, not where they're caught. Lightweight generics close that gap at compile time.

Declare NSArray<NSString *> * and the compiler flags a mismatched type before the build finishes, instead of the app crashing on a bad cast six months later. Apple shipped this feature in Xcode 7 per Swift Evolution proposal SE-0057, which formalized how NSArray<ObjectType> annotations translate into Swift generics when a header gets imported.

The catch is that the safety is compile-time only: type erasure means the object is still a plain NSArray once the binary runs.

Our iOS team has migrated several legacy Objective-C codebases to lightweight generics and logged the exact Xcode warnings and @objc dynamic errors encountered along the way.

This piece covers generic property declaration, __covariant and __contravariant annotations, parameterized classes, and using generics with Swift bridging without breaking the type contract at the call site.

What problem do lightweight generics solve?

Before Xcode 7, an NSArray or NSDictionary held elements typed as id, which meant the compiler had no idea what was actually inside the collection (Xcode 7 Release Notes).

Every read required a downcast, and every downcast was a bet: get the class wrong and you shipped a -[NSTaggedPointerString objectForKey:]: unrecognized selector crash instead of a build warning.

Lightweight generics, introduced alongside SE-0057, let a parameterized class declare what it contains, NSArray<NSURL *> * instead of a bare NSArray *, so the compiler checks element types at the call site rather than leaving that job to a runtime crash.

This bridging matters in practice: teams building modern Swift applications often work in codebases that still carry legacy Objective-C collections, so understanding how these types interoperate keeps both languages type-safe.

The payoff is compile-time safety without a new collection runtime. Apple layered generics on top of the existing id-based storage; type checking happens at compile time and the type information is erased before the binary ships, which is why the mechanism is called lightweight rather than a true generic system like C++ templates or Swift generics.

That erasure matters for anyone bridging to Swift: a generic Objective-C property imports as a specialized Swift type, but the underlying storage is still id, so a bad downcast on the Objective-C side is invisible to the Swift compiler until it's already id-as-Any at the boundary. We cover that bridging gap, and where __covariant and __contravariant affect type compatibility, below.

Declaring generic NSArray and NSMutableArray

Declare a generic NSArray or NSMutableArray by adding the element type in angle brackets after the class name: NSArray<NSString *> *names = @[@"Ola", @"Piotr"]; or NSMutableArray<NSNumber *> *scores = [NSMutableArray new];. The compiler now flags mismatches at compile time instead of letting them surface as runtime crashes.

The same syntax extends to NSDictionary<KeyType, ObjectType> * and NSSet<ObjectType> *, for example NSDictionary<NSString *, NSNumber *> *scoreboard;.

According to Apple's Lightweight Generics documentation, this parameterized-class syntax shipped with Xcode 7 and requires no runtime support because generics are compile-time only; type erasure means the binary still sees a plain NSArray object.

Generic property declaration follows the same pattern: @property (nonatomic, strong) NSArray<NSURL *> *endpoints;. One restriction trips up teams migrating from Swift: a generic property marked @objc dynamic won't compile when the generic parameter isn't an Objective-C class, since dynamic dispatch needs a concrete Objective-C runtime type, not an erased placeholder.

We've seen this raised repeatedly on Swift Forums threads discussing SE-0057, where id-as-Any bridging quietly resolves the mismatch that raw @objc dynamic cannot.

Declaring a generic Property on a custom class

A generic property declaration on a custom NSObject subclass uses the same angle-bracket syntax as NSArray, but you declare the generic parameter on the interface itself: @interface Box<ObjectType>: NSObject @property (nonatomic, strong) ObjectType object; @end. Callers get compile-time checking on box.object just like a generic collection.

Mismatch it and Xcode throws the same class of warning you'd see with NSArray<NSString *>: Incompatible pointer types assigning to 'NSNumber * _Nonnull' (aka 'NSNumber *') from 'NSString *'. That's the compiler doing type erasure bookkeeping at compile time, at runtime, ObjectType is still id.

This is the same mechanism SE-0057 describes for Swift bridging: a parameterized NSObject subclass imports into Swift with real generic parameters instead of AnyObject.

One friction point we've hit repeatedly: mark a generic property @objc dynamic from a Swift subclass and the compiler rejects it, because a fully generic class can't expose members through the Objective-C runtime that dynamic requires. Drop dynamic or make the class non-generic at that boundary.

Writing your own parameterized class

A parameterized class in Objective-C is just an NSObject subclass with a generic parameter declared in its @interface, and the compiler enforces the contract everywhere the class is used, even though the underlying storage is still id.

Here's a minimal Container class most tutorials skip writing in full:

@interface Container<__covariant ObjectType>: NSObject

- (instancetype)initWithObject:(ObjectType)object;
- (ObjectType)object;

@end

@implementation Container {
 id _object;
}

- (instancetype)initWithObject:(id)object {
 self = [super init];
 if (self) { _object = object; }
 return self;
}

- (id)object { return _object; }

@end

__covariant is what lets Container<NSString *> get passed anywhere a Container<id> is expected, mirroring how NSArray behaves.

Under the hood, _object is still typed id, this is type erasure, the compiler checks call sites, not the runtime. Nothing stops you from stuffing an NSNumber into a Container<NSString *> from unannotated legacy code.

Swift bridging gets specific here. Because ObjectType has no protocol constraint, Swift imports Container<ObjectType> as Container<AnyObject>, per SE-0057. We've hit @objc dynamic compile errors, "method cannot be marked dynamic because its generic result type is not representable in Objective-C", when a generic property on a Container subclass is exposed for KVO from Swift, which forces a manual id-typed shim.

Why Objective-C has generic classes but not generic methods

Objective-C's lightweight generics stop at the class declaration because type erasure happens at compile time, not at the message-send boundary. When you write Container<NSString *> *strings, the compiler checks that call site and then discards the parameter, the compiled binary still passes plain id through objc_msgSend.

A generic method would need to carry its type parameter through dynamic dispatch, forwarding, and KVO, none of which have a slot for it. Parameterized classes work because the type lives on the object's declared interface, checked once, everywhere the compiler sees that variable.

This is why SE-0057 describes lightweight generics as purely a compile-time annotation for bridging, not a runtime feature comparable to Swift generics, which carry witness tables at runtime and stay specialized and type-safe across compile boundaries.

We've hit this directly: declaring a generic property @objc dynamic throws a compiler error, because dynamic demands a type Objective-C runtime metadata can represent, and an erased generic parameter is not one.

If your codebase mixes ObjC parameterized classes with Swift subclasses using generics, expect that same friction at every dynamic or KVO-observed property.

Covariance and contravariance: __covariant and __contravariant

Objective-C marks generic parameters __covariant or __contravariant to control whether a parameterized class relationship follows or reverses its type arguments' relationship. Apple's own collections set the precedent: NSArray<NSString *> * assigns cleanly to NSArray<id> * because Foundation declares NSArray's element type __covariant, matching the Objective-C Lightweight Generics documentation.

Without that annotation on a custom class, the same assignment fails at compile time. We've hit this exact warning migrating a networking layer: Incompatible pointer types initializing 'Container<id> *' with an expression of type 'Container<NSString *> *'.

Adding __covariant to the generic parameter in the class's @interface resolves it, because it tells the compiler the subtype relationship of the argument (NSString * to id) should carry over to the container.

__contravariant runs the opposite direction, useful for consumer-style generic parameters where a Container<id> should be assignable where Container<NSString *> is expected. Swift's own generics enforce this more strictly at compile time than Objective-C's lightweight generics, per Swift Evolution proposal SE-0057, so mixed Swift and Objective-C codebases sometimes see stricter type-checking on the Swift side than the annotated Objective-C class enforced originally.

Compile-time only: Generics are erased at runtime

Objective-C's lightweight generics stop working the moment the compiler finishes.

Type erasure removes every generic parameter from the compiled binary, so NSArray<NSString *> * and NSArray * are the same object at runtime, with no isKindOfClass: check able to tell them apart. This is the tradeoff Swift Evolution's SE-0057 accepted deliberately: annotate for the compiler, not the runtime.

We've seen this catch teams mid-migration. Force-casting a bridged collection wrong produces NSArray<NSString *> * _Nonnull warnings like: Incompatible pointer types assigning to 'NSNumber *' from 'NSString *': a compiler warning, not a crash, and the app still ships if nobody stops to read it.

Generic property declaration adds a second trap when mixing with Swift. A parameterized class property marked @objc dynamic fails to build, since the type parameter has no representable Objective-C runtime type for the Swift runtime to key off.

Subclassing a Swift generic class from Objective-C hits the same wall: the specialized type disappears, leaving you with id and manual casting at every call site using the bridged API.

Objective-C generics vs Swift generics and bridging

Objective-C's lightweight generics and Swift generics look similar in syntax but solve different problems: one is a compiler annotation erased at build time, the other is a runtime-enforced type system with real specialization. Swift bridging papers over the gap, but not completely.

That distinction reflects the broader differences between the two languages, from type systems to memory management and syntax. When a Swift file imports a parameterized Objective-C class, the compiler treats the generic parameter as a genuine Swift generic constraint at the call site, even though the underlying Objective-C runtime still sees plain id.

That mismatch is exactly what SE-0057 was written to reconcile, mapping __covariant and __contravariant annotations onto Swift's variance rules so bridged collections like NSArray<NSString *> * import as [String] rather than [Any].

The friction shows up going the other direction. Subclass a Swift generic class from Objective-C and the generic parameter collapses back to id, so any generic property declaration on the Swift side loses its specialization the moment Objective-C code touches it. Mark that property @objc dynamic and Xcode rejects it outright, because dynamic dispatch requires an Objective-C-compatible type and a generic parameter is not one.

Objective-C generics Swift generics
Enforcement Compile-time only Compile-time and runtime
Underlying storage id, erased Specialized, retained
Bridged collections NSArray<T> Array<Element>

Teams running mixed codebases end up documenting these boundaries by hand, since neither compiler will warn you when a bridged generic silently widens to Any.

Common pitfalls: @objc dynamic and Xcode version support

Objective-C lightweight generics and the @objc dynamic modifier don't mix, and the compiler tells you exactly why.

Apple introduced lightweight generics in Xcode 7, released in September 2015, according to Apple's Objective-C generics documentation. Declare a generic property with dynamic in a class that bridges to Swift and you'll hit something close to: 'dynamic' is not supported on properties with generic parameter type.

The restriction exists because dynamic relies on Objective-C runtime dispatch, and type erasure means the runtime never sees the specialized generic parameter, only the erased id or bridged type. There's nothing for the dynamic accessor to key off.

We've run into this repeatedly on mixed codebases: a parameterized class works fine as a plain @property, then someone adds dynamic for KVO and the build breaks. The workaround is to drop dynamic on that property or wrap the generic type in a non-generic subclass exposed via NSObject.

Subclassing a Swift generic class from Objective-C hits a related wall, since Swift generics aren't representable in the Objective-C runtime at all, only their erased, @objc-compatible surface is.

FAQ: Objective-C generics

What are Objective-C lightweight generics?

Lightweight generics parameterize collection classes such as NSArray with a specific type, letting the compiler flag mismatches instead of waiting for a runtime crash. Apple shipped them in Xcode 7, per SE-0057. They matter most in codebases mixing Objective-C and Swift.

How do I declare a generic NSArray in Objective-C?

Declare a generic NSArray by adding the type in angle brackets after the class name, for example NSArray<NSString *> *names. The compiler then raises "Incompatible pointer types sending NSNumber to parameter of type NSString " if you insert the wrong object. Use this syntax on any property exposed to Swift.

Can I write my own generic class in Objective-C?

Yes, Objective-C supports parameterized classes using the same angle-bracket syntax as NSArray, for example @interface Box<ObjectType>: NSObject. The generic parameter only affects compile-time checking, since Objective-C generics rely on type erasure, so every Box behaves identically at runtime regardless of ObjectType. This catches misuse early in code review.

Why doesn't Objective-C support generic methods?

Objective-C lightweight generics apply only to class declarations, not individual methods, because the type parameter lives on the interface, not the call site. A method can reference the class's existing generic parameter but can't introduce its own. Swift generics don't share this restriction, which trips up engineers moving code between the two languages.

Are Objective-C generics enforced at runtime?

No, Objective-C generics disappear at runtime through type erasure, so an NSArray<NSString *> is indistinguishable from a plain NSArray once compiled. Compile-only checking means a cast that dodges static analysis still crashes at runtime. The __covariant and __contravariant annotations shape API design, not runtime safety, so treat them as documentation.

How do Objective-C generics bridge to Swift?

Objective-C generics bridge to Swift by mapping onto Swift's own generic types, so NSArray<NSString *> imports as [String] with full type safety. Swift bridging preserves the __covariant annotation Apple applies to Foundation collection classes. Retrofitting an existing codebase without that annotation forces Swift call sites back to [AnyObject].

Why can't @objc dynamic be used on a generic Property?

@objc dynamic can't sit on a generic property because dynamic dispatch needs Objective-C runtime type metadata that generic parameters erase at compile time. Xcode raises "Method cannot be marked dynamic because its generic parameters make it unrepresentable in Objective-C." Retrofitting KVO-observed properties with generics surfaces this error immediately.

Can Objective-C subclass a Swift generic class?

No, Objective-C can't subclass a Swift generic class, because generic Swift types never get exposed to the Objective-C runtime. The attempt fails with an "unresolved class" or linker error rather than a compile warning. Teams retrofitting mixed codebases usually add a non-generic Objective-C-compatible base class instead.

Should you retrofit generics into an existing codebase?

Retrofit lightweight generics when a class exposes public collection properties that Swift code consumes directly, the parameterized classes payoff shows up immediately in Swift call sites, where NSArray<NSString *> * imports as [String] instead of [Any]. Skip it for internal, private-header classes where no Swift caller ever sees the type.

The migration cost is real but bounded: audit generic property declarations for @objc dynamic usage first, since that's where builds break, then work outward from your most-used model classes. Teams doing similar interop cleanup on shared Objective-C/Swift codebases often underestimate this scope.

This is often a good moment to revisit your app's architecture, since choosing between MVC and MVVM can affect how cleanly model classes expose typed properties to Swift.

Our practitioner recommendation: don't retrofit generics wholesale in one pass. Convert boundary classes first, verify Swift compile output, then expand. If your team is weighing a broader Objective-C-to-Swift migration and wants a second opinion on scope and sequencing, talk to our team, we work across both languages daily and can flag the interop traps before they cost a sprint.

If you're still choosing between iOS languages and weighing a broader Objective-C-to-Swift migration, talk to our team, we work across both languages daily and can flag the interop traps before they cost a sprint.

Aleksander Popko

After graduating Computer Science and Econometrics, Aleksander spent some time working as a developer in the financial industry. Finally, he fell in love with iOS and devoted his professional life to mobile apps.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business