- Published on
- 5 min read
> Lightweight Generics in Objective-C
- Authors

- Name
- Mick MacCallum
- @0x7fs
Before Xcode 7, Objective-C collections were completely untyped. An NSArray could contain strings, numbers, custom objects, or a mix of everything. The compiler had no idea what was inside, so you had to cast constantly and hope for the best. Lightweight generics changed that by letting you declare what a collection contains.
Basic Syntax
The syntax mirrors other generic languages. You specify the element type in angle brackets:
NSArray<NSString *> *names = @[@"Alice", @"Bob", @"Charlie"];
NSDictionary<NSString *, NSNumber *> *scores = @{@"Alice": @95, @"Bob": @87};
NSSet<NSURL *> *bookmarks = [NSSet setWithObjects:url1, url2, nil];
Now the compiler knows the types. If you try to add the wrong type, you get a warning:
NSMutableArray<NSString *> *names = [NSMutableArray array];
[names addObject:@"Valid"];
[names addObject:@42]; // Warning: Incompatible pointer types
When you retrieve elements, you get the proper type without casting:
NSString *first = names.firstObject; // No cast needed
NSUInteger length = first.length; // Compiler knows this is NSString
Using Generics in Your Own Classes
You can define generic parameters on your own classes. Declare them after the class name:
// Stack.h
@interface Stack<ObjectType> : NSObject
- (void)push:(ObjectType)object;
- (ObjectType)pop;
- (ObjectType)peek;
@property (nonatomic, readonly) NSUInteger count;
@end
The ObjectType placeholder works throughout the interface. In the implementation, you use id since Objective-C generics are erased at runtime:
// Stack.m
@interface Stack ()
@property (nonatomic, strong) NSMutableArray *storage;
@end
@implementation Stack
- (instancetype)init {
self = [super init];
if (self) {
_storage = [NSMutableArray array];
}
return self;
}
- (void)push:(id)object {
[self.storage addObject:object];
}
- (id)pop {
id object = self.storage.lastObject;
[self.storage removeLastObject];
return object;
}
- (id)peek {
return self.storage.lastObject;
}
- (NSUInteger)count {
return self.storage.count;
}
@end
Callers get full type checking:
Stack<NSString *> *stringStack = [[Stack alloc] init];
[stringStack push:@"Hello"];
[stringStack push:@123]; // Warning: Incompatible pointer types
NSString *top = [stringStack pop]; // Type is NSString *, no cast needed
Covariance and Contravariance
By default, generic types are invariant. A Stack<NSString *> is not assignable to a Stack<NSObject *> even though NSString is a subclass of NSObject. You can change this with __covariant or __contravariant:
// Covariant: Can assign Stack<Subclass> to Stack<Superclass>
@interface Stack<__covariant ObjectType> : NSObject
With __covariant, this compiles:
Stack<NSString *> *stringStack = [[Stack alloc] init];
Stack<NSObject *> *objectStack = stringStack; // OK with __covariant
This matches how NSArray works in the standard library. Apple declares it as NSArray<__covariant ObjectType> so you can pass an NSArray<NSString *> where NSArray<id> is expected.
Use __contravariant for the opposite direction, which is useful for types that consume rather than produce values:
@interface Consumer<__contravariant ObjectType> : NSObject
- (void)consume:(ObjectType)object;
@end
With contravariance, a Consumer<NSObject *> can be assigned to Consumer<NSString *> because a consumer that handles any object can certainly handle strings.
The Compile-Time Limitation
Lightweight generics exist purely at compile time. At runtime, the type information is gone. This means you cannot:
Check the generic type at runtime:
// This doesn't work - there's no runtime generic type info
if ([array isKindOfClass:[NSArray<NSString *> class]]) { // Meaningless
Use generics with performSelector or other runtime features:
// Runtime doesn't know about generics
NSArray *result = [object performSelector:@selector(names)];
// Compiler doesn't know result is NSArray<NSString *>
This is different from Swift generics, which retain type information at runtime. Objective-C chose compile-time-only generics for backward compatibility with existing code and the runtime.
Nullability and Generics Together
Lightweight generics pair well with nullability annotations. Together they provide a complete type description:
@interface UserManager : NSObject
@property (nonatomic, copy, readonly) NSArray<User *> *allUsers;
- (nullable User *)userWithID:(NSString *)identifier;
- (NSArray<User *> *)usersMatchingPredicate:(NSPredicate *)predicate;
@end
This tells both the compiler and Swift exactly what to expect. The combination of generics and nullability makes Objective-C APIs bridge to Swift much more naturally.
Swift Interop Benefits
The real payoff comes when Swift imports your Objective-C code. Without generics:
// Objective-C
@property (nonatomic, copy) NSArray *names;
Swift sees this as [Any], requiring constant casting:
// Swift
let firstName = manager.names.first as? String
With generics:
// Objective-C
@property (nonatomic, copy) NSArray<NSString *> *names;
Swift sees the proper type:
// Swift
let firstName = manager.names.first // Already String?
This removes friction in mixed codebases. If you maintain Objective-C code that Swift consumes, adding generics to your collection properties and return types is one of the highest-value improvements you can make.
Practical Adoption
For existing codebases, start with the most-used interfaces. Properties that return collections are good candidates:
// Before
@property (nonatomic, copy) NSArray *items;
@property (nonatomic, strong) NSDictionary *cache;
// After
@property (nonatomic, copy) NSArray<Item *> *items;
@property (nonatomic, strong) NSDictionary<NSString *, NSData *> *cache;
Method parameters and return types benefit too:
// Before
- (NSArray *)fetchResults;
- (void)processItems:(NSArray *)items;
// After
- (NSArray<Result *> *)fetchResults;
- (void)processItems:(NSArray<Item *> *)items;
The changes are purely additive. Existing code continues to work because generics are erased at runtime. You can adopt them incrementally without breaking anything.
Lightweight generics do not give you the full power of Swift or C++ generics, but they eliminate a whole category of type mismatch bugs and make your Objective-C code play nicely with Swift. For the small effort of adding angle brackets, you get compile-time safety and better tooling support.
// Continue_Learning
Nullability Annotations in Objective-C
Objective-C's nullability annotations tell the compiler which pointers can be nil. They catch bugs at compile time and make your code bridge to Swift cleanly.
Operator Precedence Gotchas When Moving Between Swift and Objective-C
A refactoring mishap with arithmetic expressions led me down the rabbit hole of operator precedence differences between Swift and Objective-C. Here's what to watch for.
Defensive Coding with NSError in Objective-C
NSError is the standard error handling mechanism in Objective-C, but using it defensively requires more than just passing a pointer. Learn patterns that prevent crashes and make debugging easier.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.