outskirts home
Documentation menu

Framework conventions and retention

Some code is reached by paths that no source file writes down: the system discovers an intent, a test runner finds a test, the compiler lowers a result builder, the Objective-C runtime sends a message. outskirts treats these declarations as entry points, so that what lies at the outskirts of your project is code that nothing reaches, not code reached through a door the index cannot see. This page lists each convention, what is kept, the option that controls it if any, and its known limits.

To keep something outskirts does not know about, use comment commands, --retain-files or Retaining by attribute.

On this page

Framework conventions#

outskirts keeps what each of the following conventions needs, and only that. See also XCTestCase, Interface Builder, Codable and Equatable and Hashable.

App Intents#

A class, struct or enum that directly conforms to a protocol of the AppIntents module (AppIntent, AppEntity, ...) is retained with its ancestors, as are its static description properties, which the App Intents metadata processor reads by name at build time. Conformance may be declared through a protocol of your own that refines an App Intents protocol (#193); the description property is #191.

struct OpenFavorites: AppIntent { // retained
    static let title: LocalizedStringResource = "Favorites"
    static let description = IntentDescription("Opens.")
    func perform() async throws -> some IntentResult {
        .result()
    }
}

Limit: only the type and that metadata are retained; its other members follow the usual rules.

Swift Testing#

In a file that imports Testing, a type with @Suite and a function with @Test are retained, and so is the type that declares a @Test method.

import Testing

@Suite struct ParserTests { // retained
    @Test func parsesEmptyInput() {} // retained
}

Limit: detection is by attribute name in files that import Testing; only free functions and instance, class and static methods are considered, and helpers used by a test are kept only if the test references them.

SwiftUI#

Types conforming to LibraryContentProvider are retained; so are the @UIApplicationDelegateAdaptor and @NSApplicationDelegateAdaptor properties of an @main type, and the underscored witnesses of the SDK protocols TextFieldStyle (_body(configuration:)) and ShapeStyle (_apply(to:)), whose requirements the index does not link.

Previews are judged by liveness: a #Preview expansion, or a PreviewProvider, that previews a used View, Widget or ControlWidget is used along with the code it references, whereas a preview of unused code stays unused. --retain-swift-ui-previews (retain_swift_ui_previews) retains every PreviewProvider and every #Preview expansion regardless.

struct OldBadge: View { // unused: nothing shows it
    var body: some View { Text("New") }
}

#Preview { OldBadge() } // unused too: it previews unused code

Limit: a preview is live only if the view it builds is itself used, directly or through a refining protocol or superclass. See #204.

Observation and attached macros#

A stored property of an @Observable class counts as used only by explicit code, not by the accessors that the @Observable macro generates, so unused and assign-only properties are reported (#178).

@Observable final class Model {
    var title = "" // read below: used
    var draft = "" // assign-only: reported
}

Since Xcode 27, a use of $foo or _foo on a property wrapper implemented as a macro (such as @State) counts as a use of foo; initializers generated by the compiler are ignored. Limit: this is a name-based rule (the $ or _ prefix) applied to properties that have an attribute.

For SwiftData, implicit initializers added by @Model do not count, so the only explicit initializer of a @Model class is retained (#173); outskirts has no dedicated rule for @Model properties.

Result builders#

In a type with @resultBuilder, the methods the compiler calls implicitly are retained: buildExpression, buildOptional, buildEither(first:), buildEither(second:), buildArray, buildFinalResult, buildLimitedAvailability, buildPartialBlock(first:), buildPartialBlock(accumulated:next:) and buildBlock with any number of parameters.

@resultBuilder enum ListBuilder {
    static func buildBlock(_ parts: String...) -> [String] {
        parts // retained: called by the compiler
    }
}

Limit: other members of the builder follow the usual rules.

App entry points#

A type marked @main, @UIApplicationMain or @NSApplicationMain is retained with its ancestors, including when @main is on an extension of the type; for @main, the static main() is retained too.

Protobuf#

The fileprivate _GeneratedWithProtocGenSwiftVersion struct that protoc-gen-swift generates to check API compatibility is retained when it declares typealias Version and conforms to SwiftProtobuf's API version check protocol.

Limit: only that struct; the rest of generated code is judged like any other source, so exclude it with --index-exclude or report_exclude if it is not meant to be reviewed (see Excluding results).

Dynamic member and dynamic callable#

Every subscript(dynamicMember:) is retained (by name, because it may be declared in an extension of a type outside the scan), as are dynamicallyCall(withArguments:) and dynamicallyCall(withKeywordArguments:) with their parameters, and declarations marked @_dynamicReplacement.

Limit: retention is by name, so another method with one of these names is retained too.

String interpolation#

The index store does not record a call to appendInterpolation made by a string literal (apple/swift#56189), so every appendInterpolation( method declared in a struct extension is retained.

extension String.StringInterpolation {
    mutating func appendInterpolation(price: Double) {
        appendLiteral(String(format: "%.2f", price))
    }
}

Limit: such a method is retained even if no literal uses it; see #222.

Synthesized uses#

Opt-in: --retain-synthesized-uses, key retain_synthesized_uses: true.

The stored properties of a struct that is printed, interpolated or passed to String(describing:), and of a struct or class that is passed to dump, are retained, including the types of those properties, recursively.

struct Event {
    let id: Int
    let source: String // only read by print: retained
}

print(Event(id: 1, source: "app"))

Limit: a type that provides its own description (CustomStringConvertible, CustomDebugStringConvertible, TextOutputStreamable, CustomReflectable; only CustomReflectable for dump) is not retained this way, and computed properties never are. See Equatable and Hashable for the related note on reflection.

Platform conditionals#

The index store only describes the platform outskirts built for, so code that is only used on another platform would be reported as unused. To avoid these false positives, outskirts retains:

  • Declarations marked @available(<platform>, unavailable) (e.g. @available(tvOS, unavailable)), along with their members. @available(*, unavailable), swift and *ApplicationExtension platforms are not affected.
  • Declarations whose name is written inside an #if block conditioned on os(), targetEnvironment(), arch() or canImport(), including its #else branches. Matching is done by name on the syntax tree, so unrelated declarations that share the name are retained too.
  • Imports of the modules declaring a name written inside such a block, or named there. With --enable-unused-system-imports, a file whose blocks write a name that no scanned declaration accounts for keeps all its system imports.
func hapticFeedback() {} // retained: named in the block

#if os(iOS)
hapticFeedback()
#endif

This can be disabled with --disable-platform-conditional-retention. Other conditions, such as #if DEBUG, are not covered: see Code referenced only in an #if DEBUG branch.

Objective-C#

outskirts cannot analyze Objective-C code, since types may be dynamically typed.

By default, outskirts does not assume that declarations accessible by the Objective-C runtime are in use. If your project is a mix of Swift and Objective-C, enable this with --retain-objc-accessible. Swift declarations accessible by the Objective-C runtime are those explicitly annotated with @objc or @objcMembers, and classes that inherit NSObject, directly or through another class.

Alternatively, --retain-objc-annotated only retains declarations explicitly annotated with @objc or @objcMembers. Types that inherit NSObject are not retained unless they have explicit annotations. This option may uncover more unused code, with the caveat that some results may be incorrect if the declaration is used in Objective-C code. To resolve these results, add an @objc annotation to the declaration.

final class Bridge: NSObject {
    @objc func refresh() {} // kept by both options
    func helper() {} // kept by --retain-objc-accessible only
}

See also Mixed Swift and Objective-C targets for unused imports.

JavaScriptCore#

Protocols that inherit JSExport expose their members to JavaScript, which outskirts cannot analyze. Use --retain-js-accessible (retain_js_accessible) to retain every protocol that inherits JSExport, either directly or through another protocol, together with its members and the members of the conforming types that satisfy them.

@objc protocol CounterExports: JSExport { // retained
    func increment() // retained with its implementation
}

--retain-objc-accessible does not cover a protocol that is only exposed through JSExport and has no @objc annotation; where it does retain them, it also retains all other Objective-C accessible declarations. The dedicated option keeps the report focused on JavaScript-exposed declarations.

Codable#

Swift synthesizes additional code for Codable types that is not visible to outskirts and can result in false positives for properties not directly referenced from non-synthesized code. If your project contains many such types, retain all properties on Codable types with --retain-codable-properties. Alternatively, retain properties only on Encodable types with --retain-encodable-properties.

struct Payload: Encodable {
    let id: Int
    let note: String // only encoded: kept by the options above
}

If Codable conformance is declared by a protocol in an external module not scanned by outskirts, identify the protocols as Codable with --external-codable-protocols "ExternalProtocol", or as Encodable with --external-encodable-protocols; the properties and CodingKey enums of conforming types are then retained.

Equatable and Hashable#

Swift synthesizes additional code for Equatable and Hashable types that is not visible to outskirts and can result in false positives for properties not directly referenced from non-synthesized code. If your project contains many such types, retain all properties on Equatable types with --retain-equatable-properties, or all properties on Hashable types with --retain-hashable-properties. The Equatable option also retains properties on Hashable types, because Hashable refines Equatable.

Properties can also be read implicitly through reflection, when a value is described by print, debugPrint, String(describing:), String(reflecting:) or string interpolation (structs that do not provide their own description), or by dump (structs and classes). Use --retain-synthesized-uses to retain the stored properties of such types (see Synthesized uses).

Retaining by attribute#

Use --retain-attributes (retain_attributes) to retain every declaration that has one of the given attributes, as if it had a // outskirts:ignore comment. Give the attribute name without the @, e.g. --retain-attributes OptionGroup MainActor. Custom attributes such as property wrappers are supported. To limit an attribute to some declaration kinds, append them after a colon: --retain-attributes objc:function.method.instance,var.instance. A kind matches itself and its sub-kinds, so function matches every function and method, and an unknown kind is a configuration error.

retain_attributes:
  - OptionGroup
  - "objc:function.method.instance,var.instance"

Some attributes are retained without configuration, because they exist for callers that the index cannot see:

  • Functions with @c, @_cdecl or @_expose, which are exported to C or C++.
  • Functions and variables with @used or @section (and their underscored forms @_used and @_section), which are kept in the binary or placed in a named section for the linker or a section reader.
  • Functions with a body and @_silgen_name, which define the symbol they are linked under. A bodyless @_silgen_name or @_extern(c) declaration imports a symbol instead, so it is reported when unused, but its parameters are never reported: nothing could use them.
  • Types and extensions with @implementation or @_objcImplementation, together with their members.
  • The dynamicallyCall(withArguments:) and dynamicallyCall(withKeywordArguments:) methods that calls to a @dynamicCallable value are lowered to, in addition to the subscript(dynamicMember:) of @dynamicMemberLookup types.

Unused parameters without a default value#

Removing an unused parameter that has a default value rarely breaks callers, whereas removing one without a default does. Use --retain-unused-parameters-without-default (retain_unused_parameters_without_default) to retain unused parameters that have no default value, so that only the ones that are safe to remove are reported.

func log(_ message: String, level: Int = 0) {
    // 'level' is still reported: removing it is safe
    print(message)
}

XCTestCase#

Any class that inherits XCTestCase is automatically retained along with its test methods. When a class inherits XCTestCase indirectly through another class, e.g. UnitTestCase, that lives in a target outside the scan, use --external-test-case-classes UnitTestCase (external_test_case_classes) so that outskirts treats UnitTestCase as an XCTestCase subclass.

Interface Builder#

If your project contains Interface Builder files (storyboards and XIBs), outskirts takes them into account. It parses these files to find which classes, @IBOutlet properties, @IBAction methods and @IBInspectable properties are referenced. Only the members connected in an Interface Builder file are retained; @IB* members that are declared but not connected are reported as unused.

final class LoginViewController: UIViewController {
    @IBOutlet var emailField: UITextField! // connected: kept
    @IBAction func legacyTap(_ sender: Any) {} // reported
}

Files inside Xcode 16+ synchronized folders (PBXFileSystemSynchronizedRootGroup) are supported: they are collected per target, honouring each target's membership exceptions.

SPI#

Swift's @_spi attribute (System Programming Interface) marks declarations as pseudo-private, accessible only to clients that explicitly import the SPI. While these declarations are technically public, they are intended for internal or restricted use.

With --retain-public, for framework projects, all public declarations are retained, including those marked with @_spi. To check for unused code within specific SPIs anyway, list them with --no-retain-spi (no_retain_spi).

For example, with --retain-public --no-retain-spi Internal, outskirts:

  • retains regular public declarations;
  • retains @_spi(Testing) public declarations (a different SPI);
  • checks @_spi(Internal) public declarations for unused code.
@_spi(Internal) public func resetCaches() {} // checked
@_spi(Testing) public func injectClock() {} // retained

This is particularly useful for internal SPIs that should be explored for unused code while the framework's public API stays retained.

Edit this page on GitHub