[TCA Deep Dive] @CasePathable
KeyPath
Swift의 구조체와 클래슨느 프로퍼티를 참조할 수 있는 KeyPath를 사용할 수 있다. KeyPath는 특정 타입의 프로퍼티 위치를 값처럼 표현하는 기능이다.
struct User {
let id: Int
var name: String
}
\User.id // KeyPath<User, Int>
\User.name // WritableKeyPath<User, String>
- KeyPath<Root, Value>는 Root 타입 안에 있는 Value 타입 프로퍼티를 가리킨다.
let user = User(id: 1, name: "Donghyeon")
let nameKeyPath = \User.name
print(user[keyPath: nameKeyPath]) // Donghyeon
- 예를 들어 \User.name은 User 안의 name 프로퍼티를 가리키는 값이다.
var user = User(id: 1, name: "Donghyeon")
user[keyPath: \User.name] = "Kim"
print(user.name) // Kim
- var 프로퍼티라면 값을 변경하는 것도 가능하다.
즉, KeyPath를 사용하면 어떤 프로퍼티를 읽거나 수정할지 코드로 직접 고정하지 않고, 프로퍼티 접근 자체를 값처럼 전달할 수 있다.
이 개념은 SwiftUI에서도 자주 사용된다. 예를 들어 Binding, Environment, dynamicMemberLookup 같은 API 내부에서 KeyPath가 자연스럽게 활용된다.
enum에서는 KeyPath를 바로 사용할 수 없다
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
\UserAction.home // 🛑 Key path cannot refer to enum case 'home'
enum의 case에는 일반적인 KeyPath를 사용할 수 없다.
KeyPath는 기본적으로 구조체나 클래스의 저장 프로퍼티를 가리키는 기능이기 떄문이다.
enum의 case는 저장 프로퍼티가 아니다. enum은 case중 하나의 상태를 표현하는 타입이고, associated value는 특정 case일 때만 존재한다.
enum에서는 KeyPath를 바로 사용할 수 없는 이유
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
let action = UserAction.settings(SettingsAction())
- home은 항상 존재하는 프로퍼티가 아니다.
- let action 상태는 home이라는 값이 존재하지 않는다. 따라서 Swift 기본 KeyPath 시스템은 \UserAction.home처럼 enum case 내부 값을 직접 가리킬 수 없다.
enum에서는 기본적으로 switch로 꺼낸다
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
switch action {
case let .home(homeAction):
print("home action:", homeAction)
case let .settings(settingsAction):
print("settings action:", settingsAction)
}
- Swift에서 enum의 associated value를 다루는 가장 기본적인 방법은 switch 또는 if case를 사용하는 것이다.
if case let .home(homeAction) = action {
print(homeAction)
}
- 특정 case만 보고 싶다면 if case를 사용할 수 있다.
enum Destination {
case home(HomeState)
case settings(SettingsState)
}
var destination = Destination.home(HomeState(title: "Home"))
switch destination {
case var .home(state):
state.title = "New Home"
destination = .home(state)
case .settings:
break
}
- 값을 수정해야 한다면, enum을 switch로 분해한 뒤 새로운 enum 값으로 다시 만들어줘야 한다.
- 즉, enum에서는 구조체처름 특정 내부 값을 KeyPath로 바로 수정하는 것이 아니라 현재 case를 확인하고 associated value를 꺼낸 뒤 다시 감싸는 방식으로 처리한다.
정리
\User.name // 가능
- 구조체와 클래스에서는 KeyPath를 사용해 프로퍼티 접근을 값처럼 다룰 수 있다.
\UserAction.home // 불가능
if case let .home(homeAction) = action {
// homeAction 사용
}
- 하지만 enum case는 저장 프로퍼티가 아니기 때문에 Swift의 기본 KeyPath로 접근할 수 없다.
- 그래서 enum의 associated value를 다룰 때는 기본적으로 switch, if case, guard case를 사용한다.
- Swift의 기본 KeyPath는 구조체와 클래스의 프로퍼티 접근에는 강력하지만, enum case까지 표현하지는 못한다. 그래서 enum을 더 일반적으로 다루고 싶다면 Swift의 기본 KeyPath가 아니라, case를 추출하고 다시 삽입할 수 있는 별도의 추상화가 필요하다.
CasePath
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
\UserAction.home // 🛑 Key path cannot refer to enum case 'home'
- 앞에서 살펴본 것처럼 Swift의 기본 KeyPath는 구조체와 클래스의 프로퍼티에는 사용할 수 있지만, enum의 case에는 사용할 수 없다.
- 하지만 실제 앱을 개발하다 보면 enum의 associated value를 일반화해서 다루고 싶은 경우가 매우 많다.
enum Destination {
case home(HomeState)
case settings(SettingsState)
}
- 예를 들어 위와 같은 상태가 있다고 가정해 보자.
// 방법 1
if case let .home(state) = destination {
// state 사용
}
// 방법 2
switch destination {
case var .home(state):
state.title = "새 제목"
destination = .home(state)
case .settings:
break
}
- Destination이 현재 home인지 확인하고, if case let 나 switch로 수정한 뒤 다시 enum으로 감싸야 한다.
- 이러한 코드는 한두 번은 괜찮지만 enum을 자주 다루게 되면 반복되는 switch와 패턴 매칭 코드가 계속 등장하게 된다.
CasePath
앞에서 살펴본 것처럼 Swift의 기본 KeyPath는 구조체와 클래스의 프로퍼티에는 사용할 수 있지만, enum의 case에는 사용할 수 없다.
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
\UserAction.home
// 🛑 key path cannot refer to static member 'home'
- KeyPath가 구조체나 클래스 내부의 프로퍼티를 가리킨다면, CasePath는 enum의 특정 case를 가리킨다.
- Point-Free의 swift-case-paths는 이 개념을 Swift에서 사용할 수 있게 해주는 라이브러리다.
- 공식 설명에서도 CasePath를 “enum case를 위한 key path”라고 설명한다.
CasePath를 활성화하는 방법
import CasePaths
@CasePathable
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
\UserAction.Cases.home // CaseKeyPath<UserAction, HomeAction>
\UserAction.Cases.settings // CaseKeyPath<UserAction, SettingsAction>
// 타입을 추론할 수 있는 상황에서는 더 짧게 쓸 수도 있다.
\.home as CaseKeyPath<UserAction, HomeAction>
\.settings as CaseKeyPath<UserAction, SettingsAction>
- CasePath를 사용하려면 enum에 @CasePathable 매크로를 붙인다.
- 그러면 enum의 Cases 네임스페이스를 통해 case path를 만들 수 있다.
CasePath가 할 수 있는 일
// KeyPath는 어떤 값의 프로퍼티를 읽고 수정할 수 있다.
user[keyPath: \User.name] = "Blob"
user[keyPath: \.name] // "Blob"
// CasePath는 enum에서 특정 case의 associated value를 꺼내거나 수정할 수 있다.
// 현재 enum 값이 원하는 case라면 associated value를 반환하고, 다른 case라면 nil을 반환한다.
var userAction = UserAction.home(.onAppear)
userAction[case: \.home] // Optional(HomeAction.onAppear)
userAction[case: \.settings] // nil
// 값을 수정하는 것도 가능하다.
// 이 코드는 현재 userAction이 .home case일 때 내부의 HomeAction 값을 수정한다.
userAction[case: \.home] = .buttonTapped
Embed: 값을 다시 enum으로 감싸기
CasePath는 enum에서 값을 꺼내는 것뿐만 아니라, associated value를 다시 enum case로 감쌀 수도 있다.
let homeCase = \UserAction.Cases.home
let action = homeCase(.onAppear)
// UserAction.home(.onAppear)
HomeAction.onAppear
↓
UserAction.home(.onAppear)
- 이 부분이 일반 KeyPath와 다른 중요한 차이다. KeyPath는 이미 존재하는 구조체나 클래스 안으로 들어가 프로퍼티를 읽고 수정한다. 반면 CasePath는 associated value를 새로운 enum 값으로 만들 수도 있다.
case인지 확인하기
userAction.is(\.home) // true
userAction.is(\.settings) // false
// 배열에서도 유용하다.
let actions: [UserAction] = [
.home(.onAppear),
.settings(.purchaseButtonTapped),
.home(.buttonTapped)
]
let homeActionsCount = actions.count { $0.is(\.home) } // 2
- @CasePathable을 사용하면 특정 enum 값이 원하는 case인지 확인할 수도 있다.
associated value를 제자리에서 수정하기
var result = Result<String, Error>.success("Blob")
// 기본 Swift만 사용했다면 switch로 case를 꺼내고, 값을 수정한 뒤, 다시 enum으로 감싸야 한다.
switch result {
case var .success(value):
value += ", Jr."
result = .success(value)
case .failure:
break
}
// modify를 사용하면 이 반복 코드를 줄일 수 있다.
result.modify(\.success) {
$0 += ", Jr."
}
result // .success("Blob, Jr.")
- CasePath는 특정 case의 associated value를 제자리에서 수정하는 modify도 제공한다.
CasePath도 조합할 수 있다
@CasePathable
enum AppAction {
case user(UserAction)
}
@CasePathable
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
- KeyPath를 조합할 수 있듯이 CasePath도 조합할 수 있다.
- 예를 들어 액션이 중첩되어 있다고 가정해 보자.
// 그러면 AppAction에서 바로 HomeAction까지 들어가는 CasePath를 만들 수 있다.
\AppAction.Cases.user.home // CaseKeyPath<AppAction, HomeAction>
// 또는 직접 append할 수도 있다.
let appActionToUser = \AppAction.Cases.user
let userActionToHome = \UserAction.Cases.home
let appActionToHome = appActionToUser.append(path: userActionToHome) // CaseKeyPath<AppAction, HomeAction>
- 이 덕분에 큰 enum을 작은 enum 단위로 나누고, 필요한 case만 선택해서 다룰 수 있다.
Dynamic Member Lookup과 함께 사용하기
@CasePathable과 @dynamicMemberLookup을 함께 사용하면 enum case를 프로퍼티처럼 접근할 수도 있다.
@CasePathable
@dynamicMemberLookup
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
let action: UserAction = .home(.onAppear)
action.home // Optional(HomeAction.onAppear)
action.settings // nil
let actions: [UserAction] = [
.home(.onAppear),
.settings(.purchaseButtonTapped),
.home(.buttonTapped)
]
actions.compactMap(\.home) // [.onAppear, .buttonTapped]
- 배열에서도 key path 표현식처럼 사용할 수 있다.
- 이 문법은 enum을 구조체 프로퍼티처럼 다룰 수 있게 만들어준다.
// @dynamicMemberLookup 없음
let action = UserAction.home(.onAppear)
let value = action[case: \.home]
// Optional(.onAppear)
// @dynamicMemberLookup 있음
let action = UserAction.home(.onAppear)
let value = action.home
// Optional(.onAppear)
SwiftUI Binding과 CasePath
let user: Binding<User> = ...
let name: Binding<String> = user.name
- 공식 README에서도 Binding 예시를 통해 CasePath의 필요성을 설명한다.
- SwiftUI의 Binding은 WritableKeyPath를 이용해 하위 프로퍼티의 바인딩을 만들 수 있다.
enum Destination {
case home(HomeState)
case settings(SettingsState)
}
let destination: Binding<Destination> = ...
destination.home // 기본 Swift만으로는 불가능
- 하지만 enum에서는 기본적으로 이런 식의 접근이 불가능하다.
@CasePathable
enum Destination {
case home(HomeState)
case settings(SettingsState)
}
destination.home // Binding<HomeState>?
destination.settings // Binding<SettingsState>?
- CasePath를 사용하면 enum의 특정 case에 대한 바인딩을 만들 수 있다.
@dynamicMemberLookup 유무 차이
CasePath를 이해할 때는 먼저 두 가지를 구분해야 한다.
// 1. CasePath 없음
// Swift 기본 enum만 사용하는 상태
// 2. CasePath 있음
// @CasePathable을 붙여 enum case를 CasePath로 다룰 수 있는 상태
// 3. @dynamicMemberLookup 있음
// CasePath 접근을 점 문법으로 더 짧게 사용할 수 있는 상태
즉, @dynamicMemberLookup은 CasePath 자체를 만들어주는 기능이 아니다.
CasePath를 더 편한 문법으로 사용할 수 있게 해주는 옵션에 가깝다.
1. CasePath 없음
Swift 기본 enum만 사용하면 associated value를 꺼내기 위해 switch 또는 if case를 사용해야 한다.
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
let action = UserAction.home(.onAppear)
if case let .home(value) = action {
print(value)
}
// .onAppear
이 상태에서는 아래 문법을 사용할 수 없다.
action[case: \.home] // 불가능
action.home // 불가능
2. CasePath 있음, @dynamicMemberLookup 없음
@CasePathable을 붙이면 enum case를 CasePath로 다룰 수 있다.
import CasePaths
@CasePathable
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
let action = UserAction.home(.onAppear)
let value = action[case: \.home]
// Optional(.onAppear)
이때는 CasePath 기능은 사용할 수 있지만, 점 문법은 사용할 수 없다.
action[case: \.home] // 가능
action.home // 불가능
3. CasePath 있음, @dynamicMemberLookup 있음
@dynamicMemberLookup까지 붙이면 CasePath 접근을 점 문법으로 더 짧게 쓸 수 있다.
import CasePaths
@CasePathable
@dynamicMemberLookup
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
let action = UserAction.home(.onAppear)
let value = action.home
// Optional(.onAppear)
이 코드는 사실상 아래 코드와 같은 의미다.
let value = action[case: \.home]
// Optional(.onAppear)
즉, action.home은 action[case: \.home]의 축약 문법이라고 보면 된다.
최종 비교
// CasePath 없음
let action = UserAction.home(.onAppear)
if case let .home(value) = action {
print(value)
}
// .onAppear
// CasePath 있음
let action = UserAction.home(.onAppear)
let value = action[case: \.home]
// Optional(.onAppear)
// CasePath 있음 + @dynamicMemberLookup 있음
let action = UserAction.home(.onAppear)
let value = action.home
// Optional(.onAppear)
정리하면 다음과 같다.
| 상태 | 가능한 문법 | 의미 |
|---|---|---|
| CasePath 없음 | if case let .home(value) |
Swift 기본 패턴 매칭 |
| CasePath 있음 | action[case: \.home] |
CasePath로 associated value 추출 |
CasePath 있음 + @dynamicMemberLookup 있음 |
action.home |
CasePath 접근을 점 문법으로 축약 |
따라서 핵심은 다음과 같다.
@CasePathable은 enum case를 CasePath로 다룰 수 있게 해준다.@dynamicMemberLookup은action[case: \.home]을action.home처럼 짧게 쓸 수 있게 해준다.- 즉,
@dynamicMemberLookup은 필수가 아니라 편의 문법이다.