Availability and Version Compatibility

Understand the full strategy of shipping an app that must run correctly across multiple OS versions at once: the distinction between deployment target and SDK version, how to check API availability at runtime, how weak linking makes that checking possible at the linker level, and how to plan a sane migration path away from deprecated and unavailable APIs.

An earlier lesson, 'Objective-C API Annotations,' taught you the syntax framework authors use to mark up their headers — macros like API_AVAILABLE, NS_DEPRECATED, and NS_UNAVAILABLE that tell the compiler (and you) which OS versions an API requires, which APIs are on their way out, and which APIs should never be called at all. That lesson was about reading and writing those annotations correctly. This lesson is about the broader strategy that those annotations exist to support: how you, as the author of an app rather than a framework, actually ship working software when your users are spread across several different OS versions simultaneously, some of them years old. That means understanding deployment targets versus SDK versions, checking for API availability at runtime rather than assuming, understanding what weak linking does at the linker level to make that checking even possible, and having a real plan for migrating off deprecated APIs before they become unavailable ones. It also cross-references 'Swift API Customization' at the point where availability annotations directly shape how an API appears (or doesn't appear) on the Swift side.

Deployment target versus SDK version: two different numbers

These two numbers are easy to conflate but mean genuinely different things. The SDK version is which version of Apple's SDK headers and libraries you compiled against — it reflects the newest APIs your code is even allowed to reference in source, and it's effectively fixed by whichever Xcode version you built the app with (a newer Xcode ships a newer SDK). The deployment target is a separate, independently configured setting (IPHONEOS_DEPLOYMENT_TARGET or the equivalent per-platform key) that declares the oldest OS version your compiled app promises to still run on at all.

These two numbers are almost always different, and that difference is the entire reason this lesson exists. It's completely normal, and in fact typical, to build against a recent SDK (say, iOS 17) while setting a deployment target several versions back (say, iOS 15) so that the app can still install and launch on devices that haven't been updated to the newest OS. The SDK version tells the compiler 'here is the newest universe of APIs you're allowed to write code against'; the deployment target tells the compiler and the runtime environment 'but guarantee this binary still works correctly on OS versions as old as this one.'

The practical consequence is that your source code is routinely allowed to reference APIs that are newer than your deployment target — the compiler permits this because the SDK version is what governs whether an API exists in the headers at all — but calling such an API unconditionally on a device actually running an OS older than that API's introduction will crash at runtime with an unrecognized-selector or a missing-symbol failure, because the OS on that older device genuinely does not implement that API. Bridging this gap safely is exactly what the rest of this lesson's tools — availability checks and weak linking — are for.

Availability macros as a compile-time promise

As covered in more depth in 'Objective-C API Annotations,' framework headers mark each API with macros like API_AVAILABLE(ios(13.0)) (or the older NS_AVAILABLE_IOS(13.0)) directly in their declarations. These macros do two jobs at once: they document, in a machine-readable way, the minimum OS version an API requires, and they let the compiler warn you — at compile time — whenever your code calls that API from a context where it isn't provably guarded by a matching version check, given your project's deployment target.

It's worth restating the relationship precisely for this lesson's purposes: the availability macro on an API answers 'what is the oldest OS version this API exists on,' while your deployment target answers 'what is the oldest OS version my app needs to keep working on.' Whenever an API's availability version is newer than your deployment target, the compiler knows there's a real gap — the API might not exist on some device your app is still promising to support — and it uses that gap to decide when to emit an availability warning, nudging you toward one of the two safe mitigations covered next: a runtime availability check, or weak linking with a manual nil/NULL check.

Runtime availability checks: @available as the modern replacement

The direct, compiler-verified way to guard a call to a newer API is the @available expression, used as the condition of an if statement: if (@available(iOS 13.0, *)) { /* safe to call the iOS-13-only API here */ } else { /* fallback for older OS versions */ }. The compiler doesn't just treat this as an ordinary runtime boolean check — it specifically understands @available and, within the true branch, permits calls to APIs whose availability requirement is satisfied by the checked version, suppressing the availability warning it would otherwise emit; outside that branch (in the else, or anywhere unguarded), the warning returns, because the compiler has no proof the OS actually supports the API there.

The trailing , * in @available(iOS 13.0, *) is required syntax, not decoration — it accounts for every other platform your code might also target (macOS, watchOS, tvOS) by saying 'and on any of those, assume this branch is fine' when you haven't specified a version for them explicitly; you can list several platform/version pairs before the final *, such as @available(iOS 13.0, macOS 10.15, *), when your code is genuinely cross-platform and needs different minimum versions on each.

This is a direct, meaningful improvement over how availability checking used to be done: comparing [[UIDevice currentDevice] systemVersion] as a raw string against a hard-coded version string. That older approach is covered in this lesson's mistakes section, but the short version is that it's fragile string manipulation that the compiler cannot verify or reason about at all — it doesn't know the comparison is meant to gate a particular API call, so it can't suppress or validate anything, and it's trivially easy to get the string comparison itself wrong (comparing "13.0" against "9.0" lexicographically rather than numerically, for instance, which can silently misorder versions like "9.0" and "13.0").

Weak linking: how the linker makes optional APIs possible at all

Availability checks like @available only work at the level they do because of something happening one layer lower, at link time: weak linking. Normally, when your app links against a framework, every symbol your code calls is expected to be resolved and present in that framework at launch — if a symbol your binary references genuinely doesn't exist in the framework version actually installed on the device, the dynamic linker refuses to launch the app at all, with a hard failure, rather than letting it run and possibly crash later.

Weak linking changes this contract for symbols the compiler and linker already know might not exist on every deployment-target-supported OS version (which is exactly the set of API_AVAILABLE-annotated symbols newer than your deployment target): instead of requiring the symbol to be present for the app to launch at all, the dynamic linker is told it's acceptable for a weakly linked symbol to simply resolve to NULL (for a C function pointer or a class symbol) at launch time on an OS version that doesn't implement it, and the app is still allowed to launch normally. Your code is then responsible for checking that the symbol isn't NULL before actually calling through it — attempting to call through a NULL function pointer, or send a message to a class that resolved to Nil, is exactly the crash weak linking is meant to let you avoid by checking first.

In modern Objective-C, you rarely write this NULL check by hand for ordinary Objective-C methods, because @available already handles both the compile-time warning suppression and (in combination with the compiler's automatic weak-linking of appropriately annotated symbols) the actual safety of the call underneath — but the manual pattern is still exactly what you reach for with plain C functions or symbols that don't have a class-level availability check, such as testing a weakly linked C function pointer directly against NULL before calling it, or checking a weakly linked class against Nil before instantiating it. Understanding weak linking is what explains why @available is safe at all: without weak linking underneath it, referencing a newer symbol in your compiled binary would simply prevent the app from launching on older OS versions, regardless of any runtime check you wrote around the call.

Deprecated APIs and reading a deprecation notice correctly

A deprecated API, marked with something like NS_DEPRECATED_IOS(9.0, 13.0) or the modern API_DEPRECATED("message", ios(9.0, 13.0)), is one the framework author still ships and still supports calling, but no longer recommends — the two version numbers mean 'introduced in 9.0, deprecated as of 13.0.' Calling a deprecated API produces a compiler warning, not an error, and critically, a deprecation warning today does not mean the API is scheduled for removal tomorrow; some deprecated Objective-C APIs have remained fully functional and callable for many OS releases after being marked deprecated, specifically because Apple maintains strong backward compatibility guarantees for existing apps already in the App Store.

That said, treating a deprecation warning as something to silence and ignore indefinitely is a mistake, because deprecation is the framework author's advance signal that a real replacement exists and that the old path may eventually stop being maintained, optimized, or (in rarer cases) may eventually be marked outright unavailable in some future SDK. The macro API_DEPRECATED_WITH_REPLACEMENT("newMethodName", ios(9.0, 13.0)) exists specifically to make this migration path explicit and mechanical: it embeds the recommended replacement's name directly in the deprecation warning, and in many cases Xcode's Fix-it tooling can use that embedded name to offer an automatic one-click migration to the new API, turning what could be a manual hunt through documentation into a guided, low-effort update.

Unavailable APIs and the hard boundary

An unavailable API, marked with NS_UNAVAILABLE or API_UNAVAILABLE, is a different and stricter thing entirely from deprecated: it's not a warning, it's a compile-time error, meaning the API genuinely cannot be called from the annotated context at all under any circumstances your source code controls. This is used both for platform-specific exclusion (an API marked API_UNAVAILABLE(watchos) simply doesn't exist as a callable symbol when compiling for watchOS) and for deliberate API-design purposes unrelated to OS versioning at all, such as marking a designated-initializer-only class's plain -init as NS_UNAVAILABLE to force callers through the class's real initializer.

The distinction matters for migration planning specifically: a deprecated API still gives you a working, if discouraged, escape hatch while you plan and schedule a replacement, whereas an unavailable API gives you no such runway — the moment your deployment target or target platform crosses into unavailable territory, the code simply will not compile, and the migration has to happen before you can ship at all, not on a schedule of your own choosing.

Backward compatibility and migration strategy

The overall strategy this lesson has been building toward is straightforward to state even though executing it well takes discipline: choose the oldest deployment target your actual user base still meaningfully needs (checked periodically against real usage data, since supporting ancient OS versions indefinitely has a real ongoing engineering cost); use @available checks (backed by the compiler's automatic weak linking of appropriately annotated symbols) at every call site that needs an API newer than that deployment target, always pairing the modern-API branch with a genuinely functional fallback rather than an empty else; treat every deprecation warning as an actionable to-do rather than noise to silence, since API_DEPRECATED_WITH_REPLACEMENT specifically hands you the replacement's name to make that to-do concrete; and periodically raise your deployment target itself as old OS versions age out of your real user base, which lets you delete accumulated fallback branches and availability checks that are no longer earning their complexity.

This strategy is also exactly the layer at which 'Objective-C API Annotations' and this lesson connect: the annotations are the vocabulary a framework author uses to describe an API's version story, and everything in this lesson — deployment targets, @available, weak linking, deprecation-to-unavailability migration — is the discipline an app author applies in response to that vocabulary in order to ship one binary that behaves correctly across a real spread of OS versions in the field, rather than only on whatever OS version happens to be on the engineer's own device.

Code Examples

This matches the diagram's shape directly: the @available check gates whether the newer, cleaner API (overrideUserInterfaceStyle) is used, and the else branch is a genuine, working fallback rather than a no-op — on a device running iOS 12, the app still produces a reasonable dark appearance, just via a manual property instead of the system API that doesn't exist yet on that OS.

if (@available(iOS 13.0, *)) { ... } — the compiler both verifies this branch's API usage against the checked version and suppresses the availability warning specifically within it.

This is the pre-@available pattern many older Objective-C codebases still contain: comparing the device's system version string against a hard-coded version string. It can be made to work with careful use of NSNumericSearch (to avoid naive lexicographic comparison misordering "9.0" after "13.0"), but the compiler has no idea this comparison is meant to gate a particular API call, so it can neither verify the logic nor suppress the availability warning that overrideUserInterfaceStyle's usage would otherwise trigger — and a plain string equality or default comparison (without NSNumericSearch) is a classic, easy-to-introduce bug in this style of check.

[systemVersion compare:@"13.0" options:NSNumericSearch] — functionally fragile and, unlike @available, entirely invisible to the compiler's own availability checking.

Common mistake: Never gate a versioned API call on manual system-version string comparison in new code — @available exists specifically to replace this pattern with something the compiler can verify.

Because the deployment target is below 14.0, the compiler weakly links SomeNewCFunction: on a device running an OS older than 14, the dynamic linker still lets the app launch, but the symbol SomeNewCFunction simply resolves to NULL rather than a real function pointer. Explicitly checking != NULL before calling it is exactly the manual pattern weak linking requires — calling through a NULL function pointer without this check would crash immediately on an older device. Expected result: SomeNewCFunction unavailable on this OS version; skipping (on a device running an OS earlier than 14.0) if (SomeNewCFunction != NULL) — the direct, low-level analog of an @available check, needed because plain C symbols don't participate in the @available mechanism the same way annotated Objective-C APIs do.

API_DEPRECATED_WITH_REPLACEMENT embeds the recommended replacement method's name directly in the compiler's deprecation warning for oldStyleFetch, which is why Xcode is often able to offer this exact migration as a one-click Fix-it. The migrated version replaces the deprecated synchronous-feeling call with the recommended completion-handler-based API, which is the kind of concrete, planned replacement this lesson's migration-strategy section describes rather than leaving the deprecated call in place indefinitely.

API_DEPRECATED_WITH_REPLACEMENT("fetchWithCompletion:", ios(9.0, 13.0)) — the replacement's name is data the tooling can act on, not just documentation prose.

- (void)configureAppearance {
    if (@available(iOS 13.0, *)) {
        // Dark Mode API, introduced in iOS 13
        self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
    } else {
        // Fallback for iOS versions before 13: no system Dark Mode API exists,
        // so approximate the same effect manually.
        self.view.backgroundColor = [UIColor blackColor];
    }
}
NSString *systemVersion = [[UIDevice currentDevice] systemVersion];
if ([systemVersion compare:@"13.0" options:NSNumericSearch] != NSOrderedAscending) {
    // Intended to mean 'iOS 13.0 or later' — but nothing here is
    // verified by the compiler, and it's easy to get subtly wrong.
    self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
}
// SomeNewCFunction is declared with API_AVAILABLE(ios(14.0)) in its header,
// and is therefore weakly linked by the compiler for a deployment target
// below iOS 14.
if (SomeNewCFunction != NULL) {
    SomeNewCFunction();
} else {
    NSLog(@"SomeNewCFunction unavailable on this OS version; skipping");
}
// Header declaration (framework-authored):
// - (void)oldStyleFetch API_DEPRECATED_WITH_REPLACEMENT("fetchWithCompletion:", ios(9.0, 13.0));

// Old call site, produces a deprecation warning naming the replacement:
[client oldStyleFetch];

// Migrated call site:
[client fetchWithCompletion:^(NSData * _Nullable data, NSError * _Nullable error) {
    if (error) {
        NSLog(@"Fetch failed: %@", error);
        return;
    }
    NSLog(@"Fetched %lu bytes", (unsigned long)data.length);
}];

Availability decision flow for a versioned API call

Every call to an API newer than your deployment target should branch through exactly this shape: check first, use the modern path when supported, and always provide a genuine fallback rather than assuming support.

API Introduced
      |
      v
Availability Check
 |
 +-- Supported --> Use API
 |
 +-- Unsupported --> Fallback

Why this matters day to day

Almost every production Objective-C app supports more than one OS version at once, which means almost every engineer working on one eventually writes an @available check, hits a deprecation warning that needs a real decision, or debugs a crash report from a user on an older device that traces back to an API called without a proper availability guard. The deployment-target-versus-SDK-version distinction alone explains a huge share of 'it works on my device but crashes for some users' bug reports, because the engineer's own device is almost always running the newest OS, while the deployment target promises support for OS versions the engineer may not have tested on at all. Treating availability checking and deprecation migration as an ongoing discipline, not a one-time cleanup task, is what keeps an app's crash-free rate high across its full range of supported OS versions.

if ([[[UIDevice currentDevice] systemVersion] hasPrefix:@"13"]) { /* assume iOS 13+ features are safe */ } if (@available(iOS 13.0, *)) { /* safe to use iOS-13-and-later APIs here */ }

String-prefix or string-comparison checks against systemVersion are fragile (they don't correctly generalize to 'this version or later' across all real version strings), are invisible to the compiler's availability checking, and can't suppress the availability warnings that @available is specifically designed to handle correctly and verifiably.

Calling a symbol annotated API_AVAILABLE(ios(16.0)) unconditionally from code with a deployment target of iOS 13, and only silencing the compiler warning with a pragma.

Wrap the call in if (@available(iOS 16.0, *)) { ... } with a real fallback in the else branch, or explicitly check the weakly linked symbol against NULL/Nil before calling it.

Silencing the availability warning without adding an actual guard doesn't make the API exist on older OS versions — it just hides the compiler's legitimate warning that this call will crash (or, for a plain symbol, resolve to NULL) on devices running an OS older than the API's introduction.

Leaving a call to an NS_DEPRECATED-annotated API in place indefinitely because 'the warning is just a warning and it still compiles.'

Treat the deprecation warning as a scheduled migration item, especially when it's API_DEPRECATED_WITH_REPLACEMENT and already names the concrete replacement to move to.

A deprecation warning not compiling into an error today doesn't guarantee the API remains supported or performant indefinitely, and deprecated APIs are the most likely candidates to eventually become fully unavailable in a future SDK — treating deprecation as a real to-do avoids a future forced, unplanned migration.

What is the difference between an app's SDK version and its deployment target? A. They are two names for the same setting B. SDK version is which SDK you compiled against (the newest APIs your source can reference); deployment target is the oldest OS version your compiled app still promises to run on C. Deployment target is always higher than SDK version D. SDK version only matters for Swift projects, never for Objective-C

B. SDK version is which SDK you compiled against (the newest APIs your source can reference); deployment target is the oldest OS version your compiled app still promises to run on — The SDK version governs what APIs exist in the headers your source compiles against, tied to the Xcode version used. The deployment target is an independently configured minimum-OS-version promise, and it's normal for it to be several versions older than the SDK you're building with.

What does if (@available(iOS 13.0, *)) { ... } actually give you that manual systemVersion string comparison does not? A. Faster runtime performance with no other difference B. Compiler-verified gating: the compiler understands the check, permits use of matching APIs inside the branch, and suppresses the availability warning specifically there C. The ability to run iOS 13 APIs on iOS 10 devices D. Automatic App Store submission approval

B. Compiler-verified gating: the compiler understands the check, permits use of matching APIs inside the branch, and suppresses the availability warning specifically there — @available is understood natively by the compiler, which both validates that guarded API calls are safe within the true branch and suppresses the availability warning there — a plain string comparison against systemVersion is invisible to this mechanism entirely and fragile in its own right.

What does weak linking allow, at the level of the dynamic linker? A. It allows an app to reference a symbol that might not exist on every OS version it launches on, letting the symbol resolve to NULL/Nil at launch on OS versions lacking it, rather than refusing to launch at all B. It makes an app launch faster by skipping symbol resolution entirely C. It permits calling any API without any availability check ever being necessary D. It only applies to Swift binaries, never to Objective-C ones

A. It allows an app to reference a symbol that might not exist on every OS version it launches on, letting the symbol resolve to NULL/Nil at launch on OS versions lacking it, rather than refusing to launch at all — Ordinarily a missing symbol prevents an app from launching at all. Weak linking relaxes this for appropriately annotated, newer-than-deployment-target symbols: the app is still allowed to launch on an older OS, with the symbol resolving to NULL/Nil, and your code is responsible for checking before calling through it.

What is the practical difference between a deprecated API and an unavailable API? A. There is no practical difference; both are compile errors B. A deprecated API still compiles and works, producing a warning and typically a suggested replacement; an unavailable API is a compile-time error and cannot be called at all in that context C. A deprecated API cannot be called at all, while an unavailable API merely produces a warning D. Deprecated and unavailable both refer exclusively to entire frameworks, never individual methods

B. A deprecated API still compiles and works, producing a warning and typically a suggested replacement; an unavailable API is a compile-time error and cannot be called at all in that context — Deprecated (NS_DEPRECATED / API_DEPRECATED) means still callable today but discouraged, with a warning and often an embedded replacement name via API_DEPRECATED_WITH_REPLACEMENT. Unavailable (NS_UNAVAILABLE / API_UNAVAILABLE) is a hard compile-time error with no working escape hatch in that context.

Why does API_DEPRECATED_WITH_REPLACEMENT matter for migration strategy specifically? A. It automatically deletes the deprecated method from the framework B. It embeds the recommended replacement's name in the deprecation warning itself, which is concrete, actionable data that both the engineer and tools like Xcode's Fix-it can act on C. It silently rewrites your call sites without you needing to change any code D. It has no functional difference from plain API_DEPRECATED

B. It embeds the recommended replacement's name in the deprecation warning itself, which is concrete, actionable data that both the engineer and tools like Xcode's Fix-it can act on — Unlike a bare deprecation notice, API_DEPRECATED_WITH_REPLACEMENT names the concrete replacement API directly in the warning, turning 'this is deprecated, go find something else' into 'this is deprecated, here specifically is what to call instead' — which is exactly what makes automated Fix-it migration possible in many cases.

Why is it unsafe to silence an availability compiler warning with a pragma instead of adding an actual @available or NULL check? A. Because pragmas are deprecated syntax themselves B. Because silencing the warning doesn't change whether the API actually exists at runtime on older OS versions — the crash or NULL-symbol risk the warning was pointing at is still fully present C. Because pragmas only work in Swift files D. It is actually completely safe and equivalent to adding a real guard

B. Because silencing the warning doesn't change whether the API actually exists at runtime on older OS versions — the crash or NULL-symbol risk the warning was pointing at is still fully present — The availability warning exists because the API genuinely might not be present on an OS version your deployment target still supports. Suppressing the warning removes the compiler's nudge but does nothing to the actual runtime risk — the underlying crash (or, for a weakly linked symbol, a call through NULL) is exactly as likely as before.

A framework header declares: - (void)legacySync API_DEPRECATED_WITH_REPLACEMENT("syncWithCompletion:", ios(10.0, 14.0)); and a separate C function void enableExperimentalMode(void) is annotated API_AVAILABLE(ios(15.0)). Your app's deployment target is iOS 13. Write the call sites for both: migrate the deprecated call to its replacement, and safely call the weakly linked C function only when it's actually present, with a sensible fallback log message when it isn't.

// Migrated from legacySync: [client syncWithCompletion:^(NSError * _Nullable error) { if (error) { NSLog(@"Sync failed: %@", error); } }]; // Weakly linked C function, deployment target 13 < availability 15: if (enableExperimentalMode != NULL) { enableExperimentalMode(); } else { NSLog(@"Experimental mode unavailable on this OS version; continuing without it"); }

The deprecated method just needs a straightforward replacement call. The C function, being weakly linked for a deployment target below its availability version, needs an explicit NULL check rather than an @available check, since @available governs Objective-C method/class availability checking rather than gating a bare C function pointer the same way.