What are os(), canImport() and targetEnvironment() actually true for on each Apple platform, and where does canImport(AppKit) mislead?

20.7.1 · L3 · Interview

Predict first

What does this program print when built for the tvOS simulator and run there?

var tags: [String] = []
#if os(iOS)
tags.append("os(iOS)")
#endif
#if os(tvOS)
tags.append("os(tvOS)")
#endif
#if os(watchOS)
tags.append("os(watchOS)")
#endif
#if os(macOS)
tags.append("os(macOS)")
#endif
#if canImport(UIKit)
tags.append("UIKit")
#endif
#if canImport(AppKit)
tags.append("AppKit")
#endif
#if canImport(WatchKit)
tags.append("WatchKit")
#endif
#if canImport(RealityKit)
tags.append("RealityKit")
#endif
#if canImport(TVUIKit)
tags.append("TVUIKit")
#endif
#if targetEnvironment(simulator)
tags.append("simulator")
#endif
print(tags.joined(separator: " "))
  • os(tvOS) UIKit RealityKit TVUIKit simulator
  • os(tvOS) simulator
  • os(iOS) UIKit simulator

Predict first

The same program on the watchOS simulator?

var tags: [String] = []
#if os(iOS)
tags.append("os(iOS)")
#endif
#if os(tvOS)
tags.append("os(tvOS)")
#endif
#if os(watchOS)
tags.append("os(watchOS)")
#endif
#if os(macOS)
tags.append("os(macOS)")
#endif
#if canImport(UIKit)
tags.append("UIKit")
#endif
#if canImport(AppKit)
tags.append("AppKit")
#endif
#if canImport(WatchKit)
tags.append("WatchKit")
#endif
#if canImport(RealityKit)
tags.append("RealityKit")
#endif
#if canImport(TVUIKit)
tags.append("TVUIKit")
#endif
#if targetEnvironment(simulator)
tags.append("simulator")
#endif
print(tags.joined(separator: " "))
  • os(watchOS) UIKit WatchKit simulator
  • os(watchOS) WatchKit simulator
  • os(watchOS) AppKit simulator

Predict first

On Mac Catalyst the build is iOS and AppKit is importable; does the guarded NSWindow code get compiled?

#if !(os(iOS) && canImport(UIKit) && canImport(AppKit) && targetEnvironment(macCatalyst))
#error("expected: os(iOS), UIKit, AppKit and macCatalyst all true")
#endif
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
import AppKit
let w = NSWindow()
#endif
print("ok")
  • It type-checks: the #if skips NSWindow on Catalyst
  • Compile error: NSWindow unavailable in Mac Catalyst

Predict first

On visionOS, which of these conditions hold?

#if !(os(visionOS) && canImport(UIKit) && canImport(RealityKit) && canImport(ARKit) && !canImport(AppKit) && !canImport(WatchKit) && !canImport(TVUIKit))
#error("unexpected set of conditions on visionOS")
#endif
print("ok")
  • All the stated conditions hold
  • canImport(AppKit) is true

Mental model. A passport check and a customs check: os() reads the nationality printed on the passport, canImport asks what is in the suitcase, and Catalyst is a traveller with a Mac visa whose suitcase holds an AppKit box that is taped shut.

The first pass (Q414) gives the rule of thumb; this question is the measured table. Compiling one program for each platform and running it in the simulators gives: iOS os(iOS) UIKit RealityKit simulator; tvOS os(tvOS) UIKit RealityKit TVUIKit simulator; watchOS os(watchOS) UIKit WatchKit simulator (no RealityKit); macOS os(macOS) AppKit RealityKit. visionOS has os(visionOS), UIKit, RealityKit and ARKit, and no AppKit, WatchKit or TVUIKit. Three results are worth remembering. canImport(UIKit) is true on every platform except macOS, including the watch, so it does not mean “a phone-like screen”. canImport(WatchKit) and canImport(TVUIKit) are precise single-platform tests. And on Mac Catalyst the build is os(iOS) with targetEnvironment(macCatalyst), and canImport(AppKit) is true: the module is visible but its types are marked unavailable, so NSWindow() fails with “unavailable in Mac Catalyst”.

The practical rule: to mean “this is a real Mac build” write #if os(macOS), or canImport(AppKit) && !targetEnvironment(macCatalyst) if you want the framework test; to mean “a UIKit app process” use canImport(UIKit); to mean “this framework’s API exists” use canImport of that framework. The first-pass advice to guard AppKit with canImport alone is therefore wrong for a Catalyst target. Keep these tests in one small file per feature and build every destination in CI, because the branch the current build does not take is not type-checked. Not run: a device build (the conditions are the same, minus simulator) and Xcode’s “Designed for iPad” mode, which runs the iOS binary unchanged and so evaluates as iOS.

tvOS is os(tvOS) with UIKit, RealityKit and TVUIKit available.

UIKit is importable on the watch alongside WatchKit; RealityKit and AppKit are not.

canImport(AppKit) is true on Catalyst, so only the added macCatalyst test keeps NSWindow out.

visionOS has UIKit, RealityKit and ARKit and none of the other platform kits.

Common traps

  • Treating canImport(AppKit) as “macOS”. It is true on Catalyst, where AppKit types do not compile; add !targetEnvironment(macCatalyst) or use os(macOS).
  • Treating canImport(UIKit) as “not a watch”. It is true on watchOS; use canImport(WatchKit) for the watch.

They might follow up with

  • How does a Catalyst app detect itself at run time? ProcessInfo.processInfo.isMacCatalystApp; a “Designed for iPad” app uses isiOSAppOnMac.
  • Why build every platform in CI? A branch that is not taken is not type-checked.

Related questions

  • 20.1.1 How do you structure one codebase for several Apple platforms: shared cores, UI leaves, #if os, canImport and…
  • 20.3.1 Mac Catalyst, native AppKit, SwiftUI for macOS, or an iPad app on the Mac: how do you choose, and what…
  • 20.1.2 How do the platforms differ in input, process model and capabilities, and which entitlements and APIs change…

What does the * in #available(iOS 17, *) mean on tvOS, watchOS and… →