Swift KVC, KVO, and dynamic: Interoperability Guide

Swift can interoperate with Objective-C-style runtime features such as Key-Value Coding, Key-Value Observing, and dynamic dispatch. This article explains what those features do, how they connect to @objc and dynamic, and when you can rely on them.

Quick answer: KVC lets you read and write properties by name at runtime, KVO lets one object observe another object’s property changes, and dynamic asks Swift to use runtime dispatch instead of static dispatch. In Swift, these features usually require Objective-C interop, which means @objc and often NSObject.

Difficulty: Intermediate

You'll understand this better if you know: basic Swift classes and properties, what NSObject is, and the difference between compile-time checks and runtime behavior.

1. What Is KVC, KVO, and dynamic?

These three terms describe related but different parts of Swift interoperability with the Objective-C runtime:

In Swift, these features are not part of the core language model for value types and plain structs. They are mainly used with classes that participate in Objective-C interop, usually by inheriting from NSObject and exposing members with @objc.

2. Why KVC, KVO, and dynamic Matter

These features matter when you need runtime-based behavior that cannot be expressed through normal Swift property access alone. Common reasons include observing state changes, integrating with Cocoa APIs, or working with older code that expects Objective-C runtime features.

They are especially useful when a framework or API expects property names as strings or needs automatic notification when a property changes. That said, they are less common in modern pure-Swift code because Swift’s type system, property wrappers, closures, and Combine-style patterns often provide safer alternatives.

3. Basic Syntax or Core Idea

KVC with value(forKey:)

KVC reads a property using its string name. The object must support Objective-C runtime lookup, and the key must match a property that is visible to that runtime.

import Foundation

class Person: NSObject {
    @objc dynamic var name: String

    init(name: String) {
        self.name = name
    }
}

let person = Person(name: "Ava")
let value = person.value(forKey: "name")

This returns an object representation of the property value. For Swift value types, you typically need to cast the result to the expected type.

KVO with addObserver

KVO observes a property and notifies the observer when the value changes. In modern Swift, the safer API uses NSKeyValueObservation tokens.

import Foundation

class Person: NSObject {
    @objc dynamic var age: Int = 20
}

let person = Person()
let observation = person.observe(\.age, options: [.old, .new]) { object, change in
    if let newValue = change.newValue {
        print("New age: \(newValue)")
    }
}

person.age = 21

The observation object must be kept alive for the observation to continue working.

dynamic for runtime dispatch

dynamic tells Swift that the member should use runtime dispatch. In practice, it is commonly combined with @objc so the member can participate in Objective-C-style mechanisms such as KVO.

class Counter: NSObject {
    @objc dynamic var count: Int = 0
}

let counter = Counter()
counter.count = 1

Without dynamic, some runtime features may not see the property changes the way you expect.

4. Step-by-Step Examples

Example 1: Reading a property with KVC

Here is a simple KVC read. Notice that the property must be visible to the Objective-C runtime.

import Foundation

class Book: NSObject {
    @objc dynamic var title: String = "Swift Guide"
}

let book = Book()
if let title = book.value(forKey: "title") as? String {
    print(title)
}

This example demonstrates string-based lookup. It is flexible, but it also removes compile-time safety if the key is misspelled.

Example 2: Observing changes with modern KVO

KVO is often used to react to property updates. The block receives the object and the change details.

import Foundation

final class DownloadState: NSObject {
    @objc dynamic var progress: Double = 0.0
}

let state = DownloadState()
let token = state.observe(\.progress, options: [.initial, .new]) { state, change in
    if let progress = change.newValue {
        print("Progress: \(progress)")
    }
}

state.progress = 0.5
state.progress = 1.0
// Keep `token` alive for as long as you need observation.

The key point is that the observation token controls the lifetime of the observer.

Example 3: Using KVC to set a property

KVC can also write a value by key, which is useful in bridging scenarios. The value must be the right type for the property.

import Foundation

class Profile: NSObject {
    @objc dynamic var nickname: String = "guest"
}

let profile = Profile()
profile.setValue("swiftfan", forKey: "nickname")
print(profile.nickname)

This is convenient when the property name is not known until runtime, but it is also easier to break accidentally than direct property assignment.

Example 4: Why a plain Swift property is not enough

When a property is not exposed to the Objective-C runtime, KVC and KVO cannot use it in the normal way. This is one of the main boundaries to remember.

class PlainModel {
    var score: Int = 10
}

let model = PlainModel()
// `value(forKey:)` is not available in the same way here because this type
// does not participate in Objective-C KVC/KVO by default.

For pure Swift models, prefer direct access or Swift-native observation patterns instead of forcing runtime-based APIs.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Using KVO on a property that is not observable

KVO does not work on every Swift property. The type usually needs to be an NSObject subclass, and the property must be exposed to Objective-C runtime dispatch.

Problem: Swift may report that the property cannot be observed with KVO, or the observer simply never receives updates.

class Task {
    var isDone: Bool = false
}

let task = Task()
let token = task.observe(\.isDone, options: [.new]) { _, _ in
    print("Changed")
}

Fix: Use an NSObject subclass and mark the property with @objc dynamic when KVO is required.

import Foundation

final class Task: NSObject {
    @objc dynamic var isDone: Bool = false
}

let task = Task()
let token = task.observe(\.isDone, options: [.new]) { _, change in
    print(change.newValue ?? false)
}

The corrected version works because KVO can only hook into properties that participate in Objective-C runtime dispatch.

Mistake 2: Forgetting dynamic when the runtime needs it

Developers sometimes add @objc and assume that is enough. For KVO-style runtime observation, the property is commonly also marked dynamic.

Problem: The property may compile, but observation can behave unexpectedly because the runtime does not use the expected dispatch path.

import Foundation

final class Player: NSObject {
    @objc var level: Int = 1
}

let player = Player()
let token = player.observe(\.level, options: [.new]) { _, change in
    print(change.newValue ?? 0)
}

Fix: Mark the property as dynamic when you need runtime dispatch for observation or similar mechanisms.

import Foundation

final class Player: NSObject {
    @objc dynamic var level: Int = 1
}

let player = Player()
let token = player.observe(\.level, options: [.new]) { _, change in
    print(change.newValue ?? 0)
}

The corrected version makes the property eligible for runtime-based observation.

Mistake 3: Losing the observation token

The modern KVO API returns an observation object. If you do not store it, the observation ends early because the token is deallocated.

Problem: The code compiles, but the callback stops firing immediately or after the temporary value goes out of scope.

import Foundation

final class Download: NSObject {
    @objc dynamic var status: String = "idle"
}

let download = Download()
_ = download.observe(\.status, options: [.new]) { _, change in
    print(change.newValue ?? "")
}

Fix: Store the token in a property or another long-lived variable.

import Foundation

final class Download: NSObject {
    @objc dynamic var status: String = "idle"
}

final class Controller {
    private var statusObservation: NSKeyValueObservation?

    func startObserving(download: Download) {
        statusObservation = download.observe(\.status, options: [.new]) { _, change in
            print(change.newValue ?? "")
        }
    }
}

The corrected version keeps the observation alive for as long as the controller stores the token.

7. Best Practices

Prefer Swift-native APIs when you do not need runtime lookup

KVC and KVO are powerful, but they trade away some type safety. If you can use direct properties, closures, notifications, or other Swift-native patterns, those are often easier to test and refactor.

struct Settings {
    var theme: String = "light"
}

var settings = Settings()
settings.theme = "dark"

This is safer than string-based property lookup because the compiler checks the property name for you.

Keep KVC/KVO boundaries small and explicit

Only expose the properties that truly need runtime access. The more of your model you expose to KVC/KVO, the easier it is for unrelated code to depend on fragile string keys.

final class Account: NSObject {
    @objc dynamic private(set) var balance: Double = 0

    func deposit(amount: Double) {
        balance += amount
    }
}

This keeps mutation controlled while still allowing observation of the public state.

Store observation tokens and clean them up by ownership

Manage the observation token where the lifecycle is obvious, such as in the observing object. This prevents accidental early deallocation and makes teardown predictable.

final class ViewModel {
    private var observation: NSKeyValueObservation?

    func bind(person: NSObject) {
        // Store the token on the instance that owns the observation.
    }
}

This practice avoids mysterious “it worked for one line and then stopped” behavior.

8. Limitations and Edge Cases

Warning: KVC and KVO can make code harder to reason about if you use them broadly. Limit them to integration points where runtime behavior is genuinely required.

9. Practical Mini Project

In this small example, a progress tracker updates a label-like string whenever the download percentage changes. It shows KVO in a realistic ownership pattern.

import Foundation

final class DownloadTask: NSObject {
    @objc dynamic var progress: Double = 0.0
}

final class ProgressTracker {
    private var observation: NSKeyValueObservation?
    private let task: DownloadTask

    init(task: DownloadTask) {
        self.task = task
        self.observation = task.observe(\.progress, options: [.initial, .new]) { task, change in
            if let progress = change.newValue {
                print("Progress is now \(progress * 100)%")
            }
        }
    }

    func simulateWork() {
        task.progress = 0.25
        task.progress = 0.75
        task.progress = 1.0
    }
}

let task = DownloadTask()
let tracker = ProgressTracker(task: task)
tracker.simulateWork()

This example keeps the observable object and its observation token under clear ownership. It is a small but realistic pattern for runtime observation.

10. Key Points

11. Practice Exercise

Expected output: You should see the initial temperature and each updated value printed in order.

Hint: Use NSObject, mark the property as @objc dynamic, and store the observation token in a variable that stays in scope.

Solution:

import Foundation

final class WeatherReading: NSObject {
    @objc dynamic var temperature: Double = 18.0
}

let reading = WeatherReading()
let token = reading.observe(\.temperature, options: [.initial, .old, .new]) { _, change in
    let oldValue = change.oldValue
    let newValue = change.newValue
    print("old: \(oldValue?.description ?? "nil"), new: \(newValue?.description ?? "nil")")
}

reading.temperature = 19.5
reading.temperature = 21.0

// Keep `token` alive until you no longer need updates.

This solution works because the observable class participates in Objective-C runtime dispatch and the observation token remains alive.

12. Final Summary

KVC, KVO, and dynamic are interoperability tools that let Swift participate in runtime behavior from the Objective-C world. KVC reads and writes values by key name, KVO reports property changes, and dynamic ensures runtime dispatch where needed.

In modern Swift, these features should be used deliberately. They are most valuable at framework boundaries and in legacy integration code, while most app logic is easier to maintain with direct Swift properties and Swift-native observation patterns.

If you need deeper control over runtime behavior, the next step is to learn how @objc, NSObject, and Swift’s dispatch model work together in class inheritance and method overriding.