Swift package

swift-keypath-mapping

Reusable key-path conversions that preserve SwiftUI binding identity, transactions, and write behavior

Keep SwiftUI updates on the binding path

A Binding carries more than read and write closures. SwiftUI propagates update context – including animation – through its Transaction. Rebuilding a binding with Binding(get:set:) can sever that path: the value still changes, but the update may lose the animation information attached to the original binding.

KeyPathMapping expresses the same transformation as a reusable, hashable mapping that remains part of the binding’s structural path.

import SwiftUI

struct ProgressEditor: View {
  @SwiftUI.State
  private var progress: Float = 0.25

  var body: some View {
    let sliderValue = Binding<Double>(
      get: { Double(progress) },
      set: { newValue in
        progress = Float(newValue)
      }
    )

    Slider(value: sliderValue, in: 0...1)
  }
}
Binding(get:set:)
import KeyPathMapping
import SwiftUI

struct ProgressEditor: View {
  @SwiftUI.State
  private var progress: Float = 0.25

  var body: some View {
    Slider(
      value: $progress[
        convert: .to(Double.self)
      ],
      in: 0...1
    )
  }
}
KeyPathMapping

Why properties and subscripts work

Binding supports dynamic-member lookup through writable key paths. A computed property can therefore derive a binding without reconstructing it from unrelated closures:

extension BinaryFloatingPoint {
  var double: Double {
    get { Double(self) }
    set { self = Self(newValue) }
  }
}

Slider(value: $progress.double, in: 0...1)

This preserves the structural path SwiftUI understands. The trade-off is where the transformation lives: a private extension cannot be reused, while a public double property becomes part of every conforming type’s namespace.

Move transformations into the mapper namespace

KeyPathMapper gives transformations a dedicated, generic namespace. The predefined numeric conversion used above follows this shape:

extension KeyPathMapper.MutatingConversionTo
where Root: BinaryFloatingPoint {
  static func to<T: BinaryFloatingPoint>(
    _ type: T.Type
  ) -> Self where Member == T {
    .inline(
      extract: { T($0) },
      embed: { Root($0) }
    )
  }
}

Apply it through [convert:], and Binding’s dynamic-member subscript continues the original key-path chain. The conversion stays reusable without adding application-specific properties to Float, Double, or other numeric types.

Mappings use labeled subscripts: [map:] for read-only transforms, [convert:] for round-trippable conversions, [getter:] and [setter:] for one side of the behavior, and [getter:setter:] for a custom pair.

Add behavior without rebuilding the binding

Setter mappings attach write hooks while keeping the original binding path intact.

Slider(
  value: $value[
    setter: .onDidSet { newValue in
      if newValue > 0.8 {
        haptics.impact()
      }
    }
  ]
)

Enable the PredefinedConversions package trait on Swift 6.1+ to add common mappings such as .optional, .safeIndex(index), .unwrapped(with:), and numeric .to(...). Define a custom conversion only when extraction and embedding form a meaningful reusable relationship, and test its round trip when the transform can lose information.

Choose the mapping shape

MappingCapability
[map:]Read-only derivation
[convert:]Round-trippable extraction and embedding
[getter:] / [setter:]Customize one direction only
[getter:setter:]Define a complete custom pair
[mapPath:]Select a reusable mapping by key path

Choose ReadonlyConversionTo, MutatingConversionTo, or NonMutatingConversionTo according to how write-back works. Stable mapping identity can be supplied explicitly when two values of the same mapping type must compare differently.