SwiftUIを使う開発者は、1日に何十回も@Stateや@Publishedを入力します。ところが、このアットマークが正確に何をするのかと聞くと答えは分かれます。「SwiftUIの構文では?」というのはよくある誤解です。違います。プロパティラッパー(property wrapper)はSwift Evolution提案SE-0258によってSwift 5.1に導入された言語機能で、SwiftUIはその有名な利用者にすぎません。
中級シリーズ第7回です。プロパティラッパーを自作して動作原理を理解し、wrappedValueとprojectedValue($)の正体、乱用を避ける判断基準まで整理します。
問題意識 — プロパティごとに繰り返すラッピングロジック
プロパティ編では、didSetで値を検証するパターンを見ました。同じ検証が複数のプロパティに必要ならどうでしょう。音量、明るさ、進行度をすべて0…1の範囲に収めるなら、didSetを3か所コピーすることになります。
var volume: Double = 0.5 {
didSet { volume = min(max(volume, 0), 1) }
}
var brightness: Double = 0.5 {
didSet { brightness = min(max(brightness, 0), 1) }
}
// 同じコードが何度も現れます...
同じロジックをプロパティごとに書き直すのは、ジェネリクス編で見た「重複か安全性か」という問題のプロパティ版です。プロパティラッパーは、このラッピングロジックに名前を付けて再利用する仕組みです。
@propertyWrapper
struct Clamped {
private var value: Double = 0
var wrappedValue: Double {
get { value }
set { value = min(max(newValue, 0), 1) }
}
}
struct Player {
@Clamped var volume: Double
@Clamped var brightness: Double
}
player.volume = 1.5と代入しても、実際に保存されるのは1.0です。検証ロジックはClampedの1か所だけにあります。
動作原理 — アットマークが行う変換
@Clampedが魔法ではないことを確かめるには、コンパイラーの変換結果を見れば分かります。@Clamped var volume: Doubleは、おおよそ次のように展開されます。
private var _volume = Clamped() // 実際に保存されるもの:ラッパーのインスタンス
var volume: Double { // 私たちが使う名前:計算プロパティ
get { _volume.wrappedValue }
set { _volume.wrappedValue = newValue }
}
重要なのは2行です。実際に保存されるのはアンダースコア付きのラッパーインスタンスで、私たちがアクセスする名前はラッパーのwrappedValueへ転送する計算プロパティです。プロパティ編で整理した保存プロパティと計算プロパティの区別が、そのまま材料として使われています。ラッパーとは結局、「保存プロパティ+計算プロパティ+繰り返しロジック」を1つの型に包み、アットマークで展開する構文です。
この変換を知れば、ラッパーの制約も自然に理解できます。ラッパー付きプロパティが実際には計算プロパティであるため、さらにdidSetを付けたときの挙動が分かりにくいことや、ローカル変数や計算プロパティに付けられなかった制約(ローカルはSwift 5.5から許可)も、すべて「アンダースコア付きの保存領域がどこに作られるか」の問題でした。
projectedValue — ドル記号の正体
SwiftUIで、$textのようにドル記号を付けたことがあるでしょう。これもラッパーの機能です。ラッパー型にprojectedValueというプロパティを宣言すると、コンパイラが$이름という3つ目の経路を用意してくれます。
volume→ wrappedValue(値そのもの)_volume→ ラッパーインスタンス(宣言した型の内部からのみアクセス可能)$volume→ projectedValue(ラッパーが追加で提供するもの)
「追加で提供するもの」はラッパーの設計者次第です。SwiftUIの@Stateは$を通じてBinding(読み書きの経路)を公開し、Combineの@PublishedはPublisher(変更ストリーム)を公開します。同じ$構文なのに異なるものを返すのは、これが言語仕様ではなく、各ラッパーの設計判断だからです。自作する場合も同じです。上のClampedに「値が丸められたか」を返すprojectedValueを追加すれば、$volumeで「直前の代入が範囲外だったか」を確認するAPIになります。
実践レシピ — UserDefaultsラッパーで学ぶ設計
実務で最も広く使われる自作ラッパーのパターンの一つが、UserDefaultsへのアクセスです。仕組みを確認しながら作ってみましょう。
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
var wrappedValue: T {
get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
set { UserDefaults.standard.set(newValue, forKey: key) }
}
}
enum Settings {
@UserDefault(key: "hasSeenOnboarding", defaultValue: false)
static var hasSeenOnboarding: Bool
}
キー文字列の typo、キャスト、デフォルト値の処理といった繰り返しロジックをラッパーに集約し、利用側はSettings.hasSeenOnboarding = trueの1行になります。ジェネリックな<T>とinitパラメータ(key、defaultValue)がラッパーにもそのまま適用される点も、この例のポイントです。アットマークの後の括弧はラッパーのinit呼び出しです。
こうした処理がラッパーの得意分野です。保存先を変えること(UserDefaults、キーチェーン)、アクセスを包むこと(スレッドロック、ロギング)、値を整えること(範囲制限、トリミング)。共通するのは、「値の意味とは無関係な、保存・アクセスに関する技術的な関心事」だという点です。関心の分離という観点では、ラッパーはプロパティ宣言から技術的関心事を切り離すための道具です。
使いすぎに注意 — アットマークの背後に隠れる複雑さ
ラッパーの危険性は、その強みから直接生まれます。1行の代入の裏に任意のコードを隠せることです。これは最小驚きの原則と正面から衝突する機能です。
境界線として、3つの基準を提案します。1つ目は、ラッパー内では予測可能な処理だけを行うこと。値を整えたり保存先を変えたりするのは構いませんが、ネットワーク要求や画面遷移のような重い副作用を代入の裏に隠すと、デバッグ地獄になります。2つ目は、チームが把握しているラッパーだけを使うこと。@Stateのようなエコシステム標準や、チームのコードベースで文書化されたラッパーは資産です。しかし、ファイルごとに新しいアットマークが登場すると、コードレビューがラッパー定義を探すゲームになります。3つ目は、1度しか使わないロジックはそのままdidSetに置くこと。ラッパーは再利用が目的なので、2つ目の利用箇所が現れた時点で昇格させるのが適切です。YAGNI(You Aren’t Gonna Need It — 必要になるまで作らない)の原則そのものです。
最後に、最近の方向性を1つ。SwiftとSwiftUIがマクロベースへ移行する中で、Observationフレームワークの@Observableのように、ラッパーではなくマクロであるアットマークも登場しました。アットマークが付いていても、すべてがプロパティラッパーとは限りません。マクロとは何か、ラッパーとどう違うのかは、発展シリーズで扱う予定です。
まとめ
- プロパティラッパーはSwiftUI専用の機能ではなく、Swift 5.1の言語機能(SE-0258)です。プロパティごとに繰り返されるラッピング処理を型にまとめて再利用できるようにします。
- 原理は変換です。@Wrapper var xは、「アンダースコア付きのラッパーインスタンス(保存)+xという名前の計算プロパティ(アクセス経路)」に展開されます。
- $xはprojectedValueと呼ばれる3つ目のアクセス経路で、何を提供するかはラッパーの設計者が決めます(@StateはBinding、@PublishedはPublisher)。
- 保存場所の変更、アクセスのラッピング、値の整形といった技術的な関心事に適しており、重い副作用や一度きりのロジックはラッパーに入れません。
次回は中級シリーズの最終回、KeyPathです。プロパティを値として扱うバックスラッシュ構文\.nameの仕組みと、map(.name)が可能になった背景を整理します。

