Dynamic Features vs Type Safety
Synthesize the full spectrum of Objective-C's typing tools — from fully static concrete-class pointers through instancetype, __kindof, id<Protocol>, and all the way to bare id and performSelector: — into a single mental model of where each one sits between compile-time safety and runtime flexibility, and build the practical judgment for when reaching for a more dynamic tool is the right call versus when it is simply trading away safety you didn't need to give up.
This is the closing lesson of the entire 45-topic addendum, and in a real sense of the whole Objective-C course's supplementary content built on top of it. Since the very first lesson in this course — "Objective-C Syntax Mental Model" — introduced a language that is, unusually, both a strict superset of C and a message-passing system descended from Smalltalk, everything since has been circling one tension: Objective-C lets you write code that is checked at compile time, and it lets you write code that defers almost everything to runtime, and it lets you pick, deliberately, for every single variable and every single message send, how much of each you want. The Runtime internals lessons showed you the machinery underneath dynamic dispatch; "Objective-C Runtime vs Static Compilation," immediately before this one, drew the general boundary between what the compiler checks and what only the runtime resolves. This lesson closes the loop: it lines up every typing tool the language gives you, from the most static to the most dynamic, and gives you a working rule for choosing among them — because that choice, made well, is a large part of what separates fragile Objective-C code from resilient Objective-C code.
The spectrum, end to end
Objective-C's typing tools are not a binary choice between "static" and "dynamic" — they form a genuine spectrum, and understanding each point on it is what lets you place your own code deliberately instead of by habit. At one end sits a concrete class pointer type (NSString *, MyViewController *): the compiler knows exactly which interface to check every message send against, and will flag anything that doesn't match. At the other end sits bare id combined with performSelector: — the compiler checks essentially nothing about what message is being sent or whether the receiver can handle it.
Between those two poles sit several genuinely useful middle points, each trading a specific, named amount of static checking for a specific, named amount of flexibility: instancetype (static, but self-relative), __kindof (static, but deliberately loosened within a known hierarchy), and id<Protocol> (dynamic about the concrete class, but statically checked against a named set of methods). None of these is simply "better" or "worse" than the others in the abstract — each is the right tool for a specific shape of problem, and picking the wrong one is either needless fragility (reaching for id when a protocol would do) or needless rigidity (reaching for a concrete class type when the whole point of the API is that callers can be anything that conforms to a protocol).
Fully static: concrete class pointers
A pointer typed to a specific class — NSString *, NSArray<NSNumber *> *, MyCustomModel * — gives the compiler the most information it can possibly have about a value: the exact set of methods declared anywhere in that class's visible interface (including its superclasses, adopted protocols, and any category the compiler has seen a declaration for). Every message send to a concretely-typed pointer is checked against that full picture, and clang will warn — often loudly — the moment you send a message that interface doesn't support.
This is the default you should reach for whenever you actually know, and always expect, one specific class (or a narrow, fixed set reachable through normal inheritance). It costs nothing at runtime — Objective-C's dynamic dispatch still happens exactly the same way underneath — and it buys you the maximum static safety the language can offer: typos in method names, wrong argument types, and outright missing methods are almost all caught before you ever run the app.
instancetype: static, but relative to the caller
instancetype solves a specific problem plain class-typed return values can't: a factory or init method declared to return instancetype tells the compiler "whatever concrete type you called this method on (or a subclass of it), that's what comes back" — so [MySubclass someFactoryMethod] is statically known to return MySubclass *, not just the declaring class's type, with zero casting required. Before instancetype existed, the common pattern was declaring such methods to return id, which worked at runtime but threw away exactly this static information.
instancetype is still fully static — every use of it is resolved by the compiler at the call site, based on the receiver's declared static type, and it costs nothing at runtime; it is purely a compile-time convenience that happens to interact correctly with subclassing. Anywhere you're writing a class factory method, a designated initializer, or any method conceptually returning "an instance of whatever type this was called on," instancetype is almost always strictly better than declaring a return type of id or the base class.
__kindof: static, but deliberately loosened within a hierarchy
__kindof lets you say, statically, "this is some subclass of X — I don't know exactly which, and I want the compiler to let me treat it flexibly, but I still want it checked as being at least an X." Its classic use is a heterogeneous collection with a known common ancestor: NSArray<__kindof UIView *> *subviews says every element is definitely some UIView subclass, without forcing every call site to know or care which one.
The compiler still checks __kindof against the declared ancestor's interface for ordinary message sends, but it relaxes assignment compatibility: you can assign a __kindof UIView * into a UIButton * variable without an explicit cast (the compiler trusts you, similarly to how it trusts an implicit downcast elsewhere), which is exactly the ergonomic gap __kindof exists to close for APIs like UIKit's view hierarchy and Interface Builder outlet collections. It sits meaningfully more dynamic than a single concrete type, but is still fundamentally a static-typing tool — it is not the same thing as id, and code reviewers should not treat it as interchangeable with id.
id<Protocol>: the useful middle ground
id<Protocol> is the spectrum's most deliberately-designed middle point, and arguably the tool this whole lesson is building toward. The dynamic half: id<Protocol> makes no claim whatsoever about the concrete class of the object — it could be any class, known or unknown to the compiler, adopted by any framework, written by anyone — which is exactly the flexibility that makes delegate patterns, protocol-oriented APIs (see "What is a Protocol?"), and plugin-style architectures work at all.
The static half: unlike bare id, the protocol name gives the compiler something concrete to check every message send against — the full set of methods that protocol declares. Send a message to an id<SomeDelegate>-typed variable that SomeDelegate doesn't declare, and clang warns exactly as it would for a concrete class type. This is the core insight the whole lesson turns on: id<Protocol> buys back almost all of the static checking bare id throws away, while keeping the dynamic-about-concrete-class flexibility that makes protocols useful in the first place. Whenever an API's contract is genuinely "conforms to this set of methods," not "is this specific class," id<Protocol> is very often the correct choice — more correct than either a concrete class type (too restrictive) or bare id (throws away checking for no reason).
Fully dynamic: bare id and performSelector:
Bare, unqualified id makes no static claim at all — not about concrete class, and (unlike id<Protocol>) not about conformance to any named set of methods either. The compiler will let you send essentially any message to an id-typed value without complaint, because it has nothing to check that send against. performSelector: goes a step further even than that: it accepts a SEL — potentially one built dynamically from a string at runtime — meaning even the identity of the message being sent can be entirely unknown until the moment it executes (this is exactly the mechanism examined in "Objective-C Runtime vs Static Compilation" and in "performSelector: Dynamic Invocation and Its Real Limitations").
This combination is genuinely necessary for certain problems — the runtime-checks-only end of the spectrum exists because some real problems have no static shape at all (dispatching by a command name loaded from configuration, building a generic key-value coding bridge, writing infrastructure code that must work uniformly across types it cannot enumerate in advance). But it is also the point on the spectrum with the least safety net: every one of the "compiles but crashes at runtime" examples from the preceding lesson lived at exactly this end.
When dynamic behavior helps, and when it increases risk
The question is never "is dynamic behavior good or bad" in the abstract — it is always "does this specific use of dynamic behavior buy something this specific problem actually needs." The table below lays out both sides plainly, because that is exactly what the spec for this lesson asks for: a clear comparison of when reaching for a more dynamic tool earns its keep, and when it is simply forfeiting safety for no real gain.
• Heterogeneous or duck-typed collections — a mixed array processed generically, where every element just needs to respond to one message, not share a concrete class • Delegate and data-source patterns and other plugin-style architectures — the caller genuinely should not need to know the delegate's concrete class, only that it conforms to a protocol • KVO, mocking in tests, and other infrastructure that must work uniformly across types it cannot enumerate ahead of time • Protocol-oriented APIs where the real contract is a set of behaviors (id<Protocol>), not a specific inheritance chain • Genuinely data-driven dispatch — a command name, an event type, or a selector determined only at runtime by configuration, a server response, or user action
The risk side of the same table
Every one of the benefits above is real — but each has a matching cost that does not go away just because the benefit is real too, and the responsible use of any dynamic tool means consciously accepting that cost rather than not noticing it's there.
• Lost compile-time typo and type-mismatch catching — a misspelled method name or a wrong argument type on an id-typed value produces no warning at all, only a runtime crash later • Errors deferred from build time to runtime — a bug that would have been a one-line compiler error becomes an unrecognized-selector crash that only surfaces when that exact code path actually executes, possibly in production • Weaker refactoring and IDE tooling support — 'jump to definition,' 'find all callers,' and automated renames all rely on static type information that id and dynamically-built selectors don't provide, so refactors around dynamic code are more error-prone and harder to verify mechanically • Harder code review — a reviewer reading an id<Protocol> call site can check it against the protocol's declared methods; a reviewer reading a bare id or performSelector: call site often has to trace actual runtime behavior by hand to know if it's even correct
The full spectrum in one file
Each block above is legal Objective-C — the spectrum is a real, usable range of choices, not a ladder where only the top is correct. id<Protocol> is the only fully-dynamic-about-class option that still gives the compiler something concrete to check message sends against. respondsToSelector: is the runtime-side safety check that replaces the compile-time checking bare id and performSelector: don't provide.
// Fully static — the compiler checks every send against NSString's real interface
NSString *name = @"Ada";
NSUInteger len = [name length]; // checked
// instancetype — static, but relative to the receiver's actual type
@interface Widget : NSObject
+ (instancetype)widget;
@end
Widget *w = [Widget widget]; // statically known to be Widget *, no cast needed
// __kindof — static, but deliberately loosened within a known hierarchy
NSArray<__kindof UIView *> *subviews = someView.subviews;
UIButton *firstButton = subviews.firstObject; // implicit downcast permitted
// id<Protocol> — dynamic about concrete class, static about conformance
id<NSCopying> copyable = someObject;
id copy = [copyable copyWithZone:nil]; // checked against NSCopying's declared methods
// Bare id — no static claim about class or conformance at all
id anything = someOtherObject;
[anything doWhateverThisIs]; // compiles regardless of whether it exists
// performSelector: — even the message identity can be fully dynamic
SEL dynamicSel = NSSelectorFromString(configuredMethodName);
if ([anything respondsToSelector:dynamicSel]) {
[anything performSelector:dynamicSel];
}Code Examples
Because title is declared as NSString *, the compiler checks -lenght against the full, real interface it knows for NSString and finds no such method — this produces a hard warning (often escalated to an error in a strict build configuration) before the app ever runs. This is the maximum-safety end of the spectrum working exactly as intended: a typo that would otherwise become a runtime crash is caught during the build instead. Expected result: warning: no visible @interface for 'NSString' declares the selector 'lenght' NSUInteger length = [title lenght];
Common mistake: Silencing this warning (e.g. by retyping title as id "to make the warning go away") instead of fixing the actual typo — that trades a caught bug for a hidden one.
Had +defaultShape been declared to return Shape * (or id) instead of instancetype, [Circle defaultShape].setRadius: would require an explicit cast to Circle * before the compiler would allow -setRadius: to be called on the result — Shape itself doesn't declare that method. instancetype lets the same single method implementation, inherited by every subclass, statically return "whatever type you actually called this on" for every caller, with full compiler checking of everything that follows. This is purely a compile-time mechanism — at runtime, [self alloc] inside +defaultShape behaves exactly as it always would; instancetype changes nothing about execution, only about what the compiler is willing to statically verify afterward.
Circle *c = [Circle defaultShape];
Without __kindof, interactiveSubviews would need to be declared as plain NSArray<UIView *> *, and assigning firstObject (statically UIView *) into a UIButton * variable would require an explicit cast every time, even though in practice you usually do know it's a button. __kindof lets the array's element type stay honest about "some UIView subclass, I'm not pinning down which" while still permitting this kind of implicit, ergonomic narrowing at the point of use — a deliberate, static relaxation, not a retreat to id.
UIButton *button = self.interactiveSubviews.firstObject;
Common mistake: Confusing __kindof UIView * with plain id — __kindof still statically guarantees "at least a UIView," and the compiler still checks any UIView-declared method sent directly to it, unlike bare id.
saveUsingExporter: doesn't care whether it's handed a CSVExporter, a JSONExporter, or any other class anyone ever writes — id<DataExporter> deliberately gives up knowledge of the concrete class, which is exactly the flexibility a plugin-style export system needs. But it loses none of the checking that matters: any message sent to exporter is verified against DataExporter's declared methods, so a typo'd or nonexistent method call here would still be caught at compile time, exactly as it would be for a concrete class — this is the "useful middle ground" the lesson's intro promised. Expected result: Any concrete class conforming to DataExporter can be passed here and works correctly; a class that does NOT conform is flagged by the compiler at the call site, before the app runs.
• (void)saveUsingExporter:(id<DataExporter>)exporter { This is a legitimate use of the fully-dynamic end of the spectrum: the whole point of ActionRouter is to dispatch to a method whose name is not known until a server response arrives at runtime, so there is no protocol or concrete type that could express this contract statically — the shape of the problem itself is dynamic. What makes this responsible rather than reckless is the respondsToSelector: guard, which replaces the compile-time check that's unavailable here with an explicit runtime one, and the graceful fallback for the "no such method" case instead of letting it become an uncaught crash.
if ([self.target respondsToSelector:sel]) { Common mistake: Reaching for this pattern (id + performSelector:) for a problem that actually has a fixed, small set of possible messages — that case is better served by id<Protocol>, which would give the compiler real checking for exactly the same flexibility of "any conforming class."
NSString *title = @"Chapter One";
NSUInteger length = [title lenght]; // typo: "lenght" — compiler catches this@interface Shape : NSObject
+ (instancetype)defaultShape;
@end
@interface Circle : Shape
- (void)setRadius:(CGFloat)radius;
@end
@implementation Shape
+ (instancetype)defaultShape { return [[self alloc] init]; }
@end
// Called on Circle, [self alloc] inside defaultShape resolves to Circle at runtime,
// AND the compiler statically knows the return type is Circle * here — no cast needed.
Circle *c = [Circle defaultShape];
[c setRadius:5.0]; // compiles cleanly, no cast required@property (nonatomic, strong) NSArray<__kindof UIView *> *interactiveSubviews;
- (void)configureFirstButton {
// No cast required — __kindof lets this implicit narrowing compile,
// while still statically guaranteeing every element is at least a UIView.
UIButton *button = self.interactiveSubviews.firstObject;
[button setTitle:@"Continue" forState:UIControlStateNormal];
}@protocol DataExporter <NSObject>
- (NSData *)exportedData;
- (NSString *)suggestedFileName;
@end
@interface ExportCoordinator : NSObject
- (void)saveUsingExporter:(id<DataExporter>)exporter;
@end
@implementation ExportCoordinator
- (void)saveUsingExporter:(id<DataExporter>)exporter {
NSData *data = [exporter exportedData]; // checked against DataExporter
NSString *name = [exporter suggestedFileName]; // checked against DataExporter
// [exporter someRandomMethod]; // would be a compiler warning — not declared by DataExporter
[self writeData:data toFileNamed:name];
}
@end@interface ActionRouter : NSObject
@property (nonatomic, strong) id target; // deliberately untyped: could be any screen's controller
@end
@implementation ActionRouter
- (void)routeActionNamed:(NSString *)actionName {
// actionName arrives from a server-driven UI config — genuinely unknowable at compile time
SEL sel = NSSelectorFromString(actionName);
if ([self.target respondsToSelector:sel]) {
[self.target performSelector:sel];
} else {
NSLog(@"ActionRouter: target does not implement action '%@'", actionName);
}
}
@endStatic ←→ Dynamic spectrum
Moving left to right trades static, compile-time checking for runtime flexibility. id<Protocol> is the pivot point: it is dynamic about concrete class like the right-hand side, but — unlike bare id and performSelector: — it still gives the compiler a named set of methods to check every message send against, which is exactly why it earns a category of its own as "the useful middle ground" rather than being lumped in with either extreme.
Fully Static Fully Dynamic
Concrete Class ─ instancetype ─ __kindof ─ id<Protocol> ─ id ─ performSelector:
| | |
max compiler checked against a minimal/no
checking named protocol static checkingImportant Rules
Default to a concrete class pointer type whenever you genuinely expect one specific class (or its normal subclasses) — it costs nothing at runtime and buys the most static safety available.
Use instancetype for factory methods and initializers so the compiler tracks the caller's actual type through inheritance, instead of forcing a cast or falling back to id.
Use __kindof for heterogeneous collections with a known common ancestor — it keeps a real static guarantee (at least this base class) while permitting ergonomic implicit narrowing.
Reach for id<Protocol> when the real contract is 'conforms to this set of methods,' not 'is this specific class' — it is dynamic about class but still statically checked against the protocol.
Reserve bare id and performSelector: for problems that are genuinely data-driven or dynamic in shape (dispatch by a runtime-supplied name, generic bridging code) — not as a shortcut past a type error.
Any use of performSelector: or a message sent to bare id should be paired with an explicit respondsToSelector: check, since it is standing in for the static checking the compiler cannot provide there.
Idiomatic modern Objective-C uses concrete and protocol types almost everywhere, and treats bare id/performSelector: as a deliberate, narrow exception — not a default style.
Where this shows up
Idiomatic, well-maintained modern Objective-C code is overwhelmingly static: view controllers, models, and services are typed to their real concrete classes almost everywhere; factory and init methods return instancetype as a matter of course; heterogeneous UIKit-style collections use __kindof rather than plain id; and delegate, data-source, and plugin-style contracts are expressed with id<Protocol> rather than bare id, precisely because it keeps the flexibility those patterns need while losing none of the checking a concrete type would give. Bare id and performSelector: still appear in real codebases, but almost always in a small, identifiable set of places that genuinely need them — a KVC/KVO bridge, a generic serialization layer, a server-driven UI router dispatching by configured method name — each guarded by respondsToSelector: and each chosen deliberately, not because it was the path of least resistance. Recognizing which category a given piece of code falls into — "this needs to be dynamic" versus "this is dynamic out of habit" — is, more than any single API, the practical skill this whole addendum has been building toward.
@property (nonatomic, strong) id delegate; // any object, any methods, no checking at all
@property (nonatomic, weak) id<SettingsScreenDelegate> delegate; // dynamic about class, // checked against the protocol
A delegate property typed as bare id gives up all static checking for no real benefit — the whole point of a delegate is that it conforms to a known, named set of methods, which is precisely what a protocol exists to express. id<Protocol> provides the exact same "any conforming class" flexibility while restoring compile-time checking of every message sent to it.
id result = [factory buildSomething]; [result configure]; // no static checking — is -configure even real here?
SomeConcreteType *result = [factory buildSomething]; [result configure]; // checked against SomeConcreteType's real interface
When the concrete type returned by a call is actually known and fixed, typing the receiving variable as id anyway (often out of habit, or because an older API predates instancetype) throws away static checking that costs nothing to keep. Use the real, known type — or instancetype on the method declaration itself, if you're the one writing it.
[someObject performSelector:NSSelectorFromString(methodName)]; // no respondsToSelector: guard anywhere
SEL sel = NSSelectorFromString(methodName); if ([someObject respondsToSelector:sel]) { [someObject performSelector:sel]; } performSelector: is already standing in for static checking the compiler cannot provide for a dynamically-built selector — skipping the respondsToSelector: guard removes the one remaining safety net (a runtime one) and leaves nothing at all, guaranteeing a crash the first time an unexpected selector reaches this call.
[Multiple Choice] Where does id<Protocol> sit on the static-to-dynamic spectrum, and why is it described as a 'useful middle ground'? A. It is fully static, identical in behavior to a concrete class pointer B. It is dynamic about the object's concrete class, but the compiler still checks message sends against the protocol's declared methods C. It is fully dynamic, identical in behavior to bare id D. It only exists for use with Swift interoperability and has no effect in pure Objective-C code
B. It is dynamic about the object's concrete class, but the compiler still checks message sends against the protocol's declared methods — id<Protocol> makes no claim about the concrete class of the object, which is the dynamic half — but it gives the compiler a named set of methods (the protocol) to check every message send against, which is the static half it keeps that bare id does not.
[Multiple Choice] What does instancetype give you that declaring a factory method's return type as id does not? A. Faster runtime performance for the factory method B. The ability to override the factory method in a subclass C. Static knowledge, at the call site, of the caller's actual receiver type — so no cast is needed to use subclass-specific methods on the result D. Automatic memory management the id return type lacks
C. Static knowledge, at the call site, of the caller's actual receiver type — so no cast is needed to use subclass-specific methods on the result — instancetype is resolved by the compiler based on the receiver's declared static type, so [Subclass factoryMethod] is statically known to return Subclass *, letting subclass-specific methods be called on the result without a cast — id would require an explicit cast to get the same access.
[True / False] __kindof UIView * is functionally equivalent to plain id for the purposes of compiler checking. A. True B. False
B. False — False. __kindof UIView * still statically guarantees the value is at least a UIView, and the compiler checks any UIView-declared method sent to it directly — it only relaxes assignment compatibility for implicit narrowing to a more specific subclass, unlike id which checks nothing.
[Multiple Choice] Which scenario is the strongest legitimate justification for using bare id together with performSelector:, guarded by respondsToSelector:? A. A delegate property whose small, fixed set of methods is known ahead of time B. A factory method that always returns instances of one specific class C. Dispatch to a method whose name is only known at runtime, such as one loaded from a server-driven configuration D. A heterogeneous array of UIView subclasses accessed by index
C. Dispatch to a method whose name is only known at runtime, such as one loaded from a server-driven configuration — A fixed, known delegate contract is better served by id<Protocol>, and a fixed-return factory by instancetype, and a UIView-subclass array by __kindof. Only a genuinely runtime-determined method name has no static shape a protocol or concrete type could express, which is exactly the case bare id + performSelector: (with a respondsToSelector: guard) is meant for.
[Multiple Choice] What specific static-checking capability does a concrete class pointer type (e.g. NSString *) give the compiler that id<Protocol> does not? A. Checking against every method in the class's full real interface, not just the methods declared by one protocol B. Faster message dispatch at runtime C. Automatic conformance to NSCopying D. The ability to use instancetype in its methods
A. Checking against every method in the class's full real interface, not just the methods declared by one protocol — A concrete class type lets the compiler check a message send against the class's entire visible interface (all its own methods, superclass methods, and adopted protocols), whereas id<Protocol> only gives the compiler the specific protocol's declared methods to check against — narrower by design, in exchange for not committing to one concrete class.
[True / False] According to this lesson, idiomatic modern Objective-C code tends to use bare id and performSelector: as its default style rather than as a narrow exception. A. True B. False
B. False — False. Idiomatic modern Objective-C uses concrete class types and protocol types (id<Protocol>) almost everywhere, and reserves bare id and performSelector: for the narrow set of genuinely dynamic-shaped problems that actually need them — not as a general-purpose default.
[Multiple Choice] What is the main practical risk of using id where a protocol type would have worked equally well? A. id-typed values consume more memory than protocol-typed values B. Typos and wrong-argument mistakes in message sends go uncaught at compile time and surface later as runtime crashes instead C. id cannot be passed as a method argument D. The Objective-C runtime refuses to message id-typed variables
B. Typos and wrong-argument mistakes in message sends go uncaught at compile time and surface later as runtime crashes instead — The core cost of id versus id<Protocol> (when a protocol would have described the real contract just as well) is entirely about lost static checking — the same category of mistake a protocol would catch at build time instead surfaces only when that code path actually runs.
Take a "NotificationDispatcher" class that currently has a property "id observer" and a method "-notify" that calls [self.observer performSelector:@selector(handleNotification:)] with no guard. First, identify why this is riskier than it needs to be. Then rewrite it twice: once using a proper protocol (id<NotificationObserving> with a declared -handleNotification: method) to restore static checking, and once showing how you'd justify keeping the id/performSelector: version instead — under what specific circumstance would that be the more honest choice?
// Riskier version — id + performSelector: with no protocol and no guard @interface NotificationDispatcher : NSObject @property (nonatomic, weak) id observer; @end @implementation NotificationDispatcher - (void)notify { [self.observer performSelector:@selector(handleNotification:) withObject:self]; // unchecked } @end // Safer version — id<Protocol> restores static checking for the common case @protocol NotificationObserving <NSObject> - (void)handleNotification:(id)sender; @end @interface NotificationDispatcher : NSObject @property (nonatomic, weak) id<NotificationObserving> observer; @end @implementation NotificationDispatcher - (void)notify { [self.observer handleNotification:self]; // checked against NotificationObserving } @end // The id/performSelector: version is only justified if the actual method name // varies per observer and is not fixed at compile time — e.g. chosen by a // per-observer configuration string — which is a genuinely different, dynamic- // shaped problem than "call this one fixed method on any conforming observer."
The protocol version should look like the id<DataExporter> example above. For the second part, the honest justification is exactly the ActionRouter case: only when the method name genuinely isn't known until runtime (e.g. it varies per observer type and is chosen by configuration) does bare id/performSelector: earn its place over a protocol.