Video #258: Macro Case Paths: Part 2
Episode: Video #258 Date: Nov 20, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep258-macro-case-paths-part-2

Description
We have now totally reimagined the design of our case paths library to create actual key paths for enum cases, but there is some boilerplate involved. Let’s create a macro that eliminates all of it and explore a few of the possibilities it unlocks.
Video
Cloudflare Stream video ID: 2f707bac875d6c98dff1f9e85ddc7b23 Local file: video_258_macro-case-paths-part-2.mp4 *(download with --video 258)*
Transcript
— 0:05
So, we’ve done some pretty interesting things just now. We decided to house all of the case paths for an enum inside an inner Cases type, that way we don’t pollute the enum with a bunch of superfluous properties that will clutter autocomplete and the documentation of your types. And then we flipped the notion of “case path” on its head by instead thinking of it as a key path that locates a case path inside that Cases type. That instantly gave us key path syntax to procure case paths, and gave us type inference and nice autocomplete for free! Stephen
— 0:40
But of course what we have done so far is not how anyone would want to write their applications. No one is going to define the Cases type for each one of their enums and then fill those types with properties for each case in the enum. That is a long, laborious process that is very easy to get wrong.
— 0:57
And this is a perfect use case for a macro. We should be able to annotate our enums with some kind of @CasePathable macro, and have all of that code generated for us automatically. This will be the first truly useful macro that we have built on Point-Free, and it is not a trivial one. There are some tricky aspects to it, and luckily we have our new MacroTesting library to help us each step of the way.
— 1:19
So, let’s see what it takes to write a macro to generate all of this case path boilerplate automatically. Creating the macro
— 1:26
To explore the macro we are going to hop over to the CasePaths project, and we are going to cut-and-paste the little bit of library code we just introduced in the Composable Architecture project, and make it public: public protocol CasePathable { associatedtype Cases static var cases: Cases { get } } public typealias CaseKeyPath<Root: CasePathable, Value> = KeyPath<Root.Cases, CasePath<Root, Value>>
— 1:43
This will go in a new CasePathable.swift file.
— 1:53
This shows just how small of a change we are proposing to case paths in order to unlock all of this potential. The vast majority of work that needs to be done to make this little bit of library code shine will be in the macro that generates all of those case path properties.
— 2:06
The first changes we need to make are in the Package.swift. We need to upgrade the package to be Swift 5.9 so that we can actually use macros: // swift-tools-version:5.9
— 2:15
It’s worth noting that in the final release of these tools in the CasePaths library we will not have to drop Swift 5.8 and below support. It will be possible to support older versions of Swift and the new macro tools at the same time.
— 2:30
We will import CompilerPluginSupport so that we get access to the new macro targets: import CompilerPluginSupport
— 2:36
We will add a dependency on our MacroTesting library so that we can write tests for the new @CasePathable macro, as well as a dependency on Apple’s SwiftSyntax library which is technically not required to write macros but without it it is incredibly difficult to do so: .package( url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.2.0" ), .package( url: "https://github.com/apple/swift-syntax.git", from: "509.0.0" ),
— 3:00
And we will add a new macro target and test target: .macro( name: "CasePathsMacros", dependencies: [ .product( name: "SwiftSyntaxMacros", package: "swift-syntax" ), .product( name: "SwiftCompilerPlugin", package: "swift-syntax" ), ] ), .testTarget( name: "CasePathsMacrosTests", dependencies: [ "CasePathsMacros", .product( name: "MacroTesting", package: "swift-macro-testing" ), ] ),
— 3:41
We will then make the main “CasePaths” target depend on this new macros target: .target( name: "CasePaths", dependencies: [ "CasePathsMacros", .product( name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay" ) ] ),
— 3:50
And to make SPM happy we need to create these directories and have at least one file in each so let’s do that real quick…
— 4:20
OK, SPM is happy again, and now we can start implementing our macro.
— 4:24
It all begins by defining the signature of our macro. This is done in the main library rather than the newly added macro library, and that is because the main library should not have to depend on SwiftSyntax just so that it can advertise to the world what macros it exposes. The only thing that needs to depend on SwiftSyntax is the actual macro compiler plugin, which is run as part of the build process of your application, but is never actually used in your actual application’s code.
— 4:49
So, let’s create a new file called Macros.swift…
— 4:54
There are a number of things that go into defining the signature of a macro. We can start by defining its signature in a fashion that is similar to defining the signature of a function, except instead of using the func keyword we use the macro keyword: public macro CasePathable()
— 5:11
This declares that we have a macro called CasePathable and it takes no arguments.
— 5:16
But then, unlike a function signature, you don’t declare its implementation right in this file inside curly braces, but rather you tell Swift which type inside which module holds the implementation: public macro CasePathable() = #externalMacro( module: <#String#>, type: <#String#> )
— 5:35
The module is the one we just created, “CasePathMacros”, and the type hasn’t be created yet but we will soon, and at that time we will call it CasePathableMacro : public macro CasePathable() = #externalMacro( module: "CasePathMacros", type: "CasePathableMacro" )
— 5:46
But, in addition to defining the signature of the macro, we must also define what type of macro it is. Currently there are only two options. There are “attached” macros and “freestanding” macros, but there may be more types coming to Swift in the future.
— 6:00
A freestanding macro is one that is invoked with the # symbol and can be used in any place in Swift code where expressions are allowed. We’ve seen examples of this in the past with the #stringify macro that comes with the macro template in SPM.
— 6:18
An attached macro is one that is attached to some existing Swift code, such as a type, extension, property, and so on. Such macros are invoked with the @ symbol, and we also saw an example of this in past episodes while looking at Apple’s repo of example macros. One such example was the @MetaEnum macro that would create an inner enum of any existing enum that held just the cases with no associated data: @MetaEnum enum Loading { case loaded(String) case inProgress }
— 6:49
This is exactly the type of macro we want for @CasePathable , and so we can declare that like so: @attached(member) public macro CasePathable() = #externalMacro( module: "CasePathsMacros", type: "CasePathableMacro" )
— 7:29
And now this compiles, though we have a warning telling us that Swift was not actually able to find the macro implementation. That is OK because we haven’t actually done that yet.
— 7:38
Let’s hop over to the CasePathableMacro.swift file and start working on an implementation. We will define a new type to implement the MemberMacro protocol, which is what the Swift compiler will invoke in order to run our macro code on the raw source string where the @CasePathable macro is applied: public enum CasePathableMacro: MemberMacro { }
— 7:53
But to get access to MemberMacro we need to import SwiftSyntaxMacros : import SwiftSyntaxMacros
— 8:00
The MemberMacro protocol has one requirement, but it is an intense one: import SwiftSyntax import SwiftSyntaxMacros public enum CasePathableMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { [] } }
— 8:19
It has 3 arguments:
— 8:21
node , which is the actual SwiftSyntax node of the macro itself. Typically you use this value to analyze the arguments passed to the macro, but in our case we do not have any arguments.
— 8:30
Then there’s declaration , which is the syntax node that the macro was applied to. This will be the enum that the @CasePathable macro is applied to, and we can use this value to analyze all of the cases held inside.
— 8:41
And finally there’s context , which can be used to emit diagnostics for your macro, among other things.
— 8:47
And the method returns an array of syntax nodes that will be inserted into the type that the macro was attached to. Right now we are just returning an empty array to get things compiling, but soon we will need to do the work to scan for all the cases in the enum, and insert a new Cases inner type with properties for each case.
— 9:06
But before doing that, let’s get some testing infrastructure into place so that we can immediately see the results of our work in this macro. In order to run the macro as a compiler plugin we need to first register it as a plugin, and you do that by creating a new file, say Plugins.swift, with a @main entry point to describe all of the macros we are providing: import SwiftCompilerPlugin import SwiftSyntaxMacros @main struct CasePathsPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ CasePathableMacro.self, ] }
— 10:00
Right now we just have a single macro, but in the future if we add more we would add them to this array.
— 10:06
Now we can jump over to the CasePathableMacroTests.swift file and start writing our first test. We will need to import the macros module, as well as MacroTesting and XCTest: import CasePathsMacros import MacroTesting import XCTest
— 10:18
And we will create a class for the test case: final class CasePathableMacroTests: XCTestCase { }
— 10:20
And before writing a test we will override the invokeTest of this class so that we can provide the macros we are testing for every single test method automatically: override func invokeTest() { withMacroTesting( macros: [CasePathableMacro.self] ) { super.invokeTest() } }
— 10:45
There’s no need to specify this in every single test method.
— 10:49
Further, since we are going to be iterating on this macro a lot, we are going to go ahead and force the MacroTesting library to be in “record” mode, which means each time the test is run a fresh macro expansion will be automatically generated in this file: override func invokeTest() { withMacroTesting( isRecording: true, macros: [CasePathableMacro.self] ) { super.invokeTest() } }
— 11:09
And now we can write our first test for @CasePathable : func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) } """ } }
— 11:37
And upon running this test we get a test failure letting us know that a new macro expansion was recorded, and that expansion is written directly to the file we are in: func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) } """ } expansion: { """ enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) } """ } }
— 11:51
So, that’s pretty amazing, but also we haven’t accomplished too much. Nothing was expanded into our code because we haven’t truly implemented the macro yet.
— 12:02
Instead of returning an empty array from the expansion method of CasePathableMacro , let’s actually return some real Swift source code to be inserted into the type. We know we want to create an inner type to house all of the case paths and provide static access to a value from that type, so we can start with those basics: [ """ struct Cases { } static let cases = Cases() """ ]
— 12:22
If we hop back over to tests and re-run the suite we will see the newly expanded macro code automatically inserted: func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) } """ } expansion: { """ enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) struct Cases { } static let cases = Cases() } """ } }
— 12:29
OK, we’ve gotten our first bits of macro generated Swift code! Fleshing out the macro
— 12:42
So we now have our macro generating the inner Cases type that will eventually house the case paths, as well as a static value of Cases which is then the thing that we want to be able to key path into. Brandon
— 12:53
Next we need to generate a case path for each case of the enum and stick it in the Cases type. That should give us those key paths immediately, for free.
— 13:03
The next thing we want to do is find all the cases in the enum so that we can generate a property in Cases for each case. I know we will need to use the declaration node provided to the expansion method, but I don’t know by heart exactly how to traverse into the complex Swift syntax tree to find that information. So, let’s put a breakpoint in the expansion method and run the test suite again so that we can use lldb to explore the syntax node:
— 13:10
If we try to print out the declaration node we will find that it doesn’t work very well: (lldb) po declaration error: <EXPR>:8:1: error: cannot find 'declaration' in scope declaration ^~~~~~~~~~~
— 13:27
It seems that lldb still can’t handle opaque some types, and so we need to convert those to regular generics: public static func expansion<D: DeclGroupSyntax>( of node: AttributeSyntax, providingMembersOf declaration: D, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { … }
— 13:45
And now when we run again we can properly print the declaration: (lldb) po declaration EnumDeclSyntax ├─attributes: AttributeListSyntax │ ╰─[0]: AttributeSyntax │ ├─atSign: atSign │ ╰─attributeName: IdentifierTypeSyntax │ ╰─name: identifier("CasePathable") ├─modifiers: DeclModifierListSyntax ├─enumKeyword: keyword(SwiftSyntax.Keyword.enum) ├─name: identifier("Action") ╰─memberBlock: MemberBlockSyntax ├─leftBrace: leftBrace ├─members: MemberBlockItemListSyntax │ ├─[0]: MemberBlockItemSyntax │ │ ╰─decl: EnumCaseDeclSyntax │ │ ├─attributes: AttributeListSyntax │ │ ├─modifiers: DeclModifierListSyntax │ │ ├─caseKeyword: keyword(SwiftSyntax.Keyword.case) │ │ ╰─elements: EnumCaseElementListSyntax │ │ ╰─[0]: EnumCaseElementSyntax │ │ ├─name: identifier("feature1") │ │ ╰─parameterClause: EnumCaseParameterClauseSyntax │ │ ├─leftParen: leftParen │ │ ├─parameters: EnumCaseParameterListSyntax │ │ │ ╰─[0]: EnumCaseParameterSyntax │ │ │ ├─modifiers: DeclModifierListSyntax │ │ │ ╰─type: MemberTypeSyntax │ │ │ ├─baseType: IdentifierTypeSyntax │ │ │ │ ╰─name: identifier("Feature1") │ │ │ ├─period: period │ │ │ ╰─name: identifier("Action") │ │ ╰─rightParen: rightParen │ ╰─[1]: MemberBlockItemSyntax │ ╰─decl: EnumCaseDeclSyntax │ ├─attributes: AttributeListSyntax │ ├─modifiers: DeclModifierListSyntax │ ├─caseKeyword: keyword(SwiftSyntax.Keyword.case) │ ╰─elements: EnumCaseElementListSyntax │ ╰─[0]: EnumCaseElementSyntax │ ├─name: identifier("feature2") │ ╰─parameterClause: EnumCaseParameterClauseSyntax │ ├─leftParen: leftParen │ ├─parameters: EnumCaseParameterListSyntax │ │ ╰─[0]: EnumCaseParameterSyntax │ │ ├─modifiers: DeclModifierListSyntax │ │ ╰─type: MemberTypeSyntax │ │ ├─baseType: IdentifierTypeSyntax │ │ │ ╰─name: identifier("Feature1") │ │ ├─period: period │ │ ╰─name: identifier("Action") │ ╰─rightParen: rightParen ╰─rightBrace: rightBrace
— 13:59
There’s a lot here, but we can see that deep inside this tree is identifier("feature1") and identifier("feature2") , which are the case names of the enum the macro is attached to. And then deeper in the tree is type: MemberTypeSyntax which describes the type of data held in each case.
— 14:39
If we traverse into the declaration’s memberBlock , and then into the members , which is a collection, we can then compactMap onto it to pluck out the decl and try to cast it to a EnumCaseDeclSyntax : (lldb) po declaration.memberBlock.members.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } ▿ 2 elements - 0 : EnumCaseDeclSyntax ├─attributes: AttributeListSyntax ├─modifiers: DeclModifierListSyntax ├─caseKeyword: keyword(SwiftSyntax.Keyword.case) ╰─elements: EnumCaseElementListSyntax ╰─[0]: EnumCaseElementSyntax ├─name: identifier("feature1") ╰─parameterClause: EnumCaseParameterClauseSyntax ├─leftParen: leftParen ├─parameters: EnumCaseParameterListSyntax │ ╰─[0]: EnumCaseParameterSyntax │ ├─modifiers: DeclModifierListSyntax │ ╰─type: MemberTypeSyntax │ ├─baseType: IdentifierTypeSyntax │ │ ╰─name: identifier("Feature1") │ ├─period: period │ ╰─name: identifier("Action") ╰─rightParen: rightParen - 1 : EnumCaseDeclSyntax ├─attributes: AttributeListSyntax ├─modifiers: DeclModifierListSyntax ├─caseKeyword: keyword(SwiftSyntax.Keyword.case) ╰─elements: EnumCaseElementListSyntax ╰─[0]: EnumCaseElementSyntax ├─name: identifier("feature2") ╰─parameterClause: EnumCaseParameterClauseSyntax ├─leftParen: leftParen ├─parameters: EnumCaseParameterListSyntax │ ╰─[0]: EnumCaseParameterSyntax │ ├─modifiers: DeclModifierListSyntax │ ╰─type: MemberTypeSyntax │ ├─baseType: IdentifierTypeSyntax │ │ ╰─name: identifier("Feature1") │ ├─period: period │ ╰─name: identifier("Action") ╰─rightParen: rightParen
— 15:00
And then further we can flatMap on that collection of EnumCaseSyntax s in order to pluck out the elements collection, which then gives us a flat collection of EnumCaseDeclSyntax s: (lldb) po declaration.memberBlock.members.flatMap { $0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [] } ▿ 2 elements - 0 : EnumCaseElementSyntax ├─name: identifier("feature1") ╰─parameterClause: EnumCaseParameterClauseSyntax ├─leftParen: leftParen ├─parameters: EnumCaseParameterListSyntax │ ╰─[0]: EnumCaseParameterSyntax │ ├─modifiers: DeclModifierListSyntax │ ╰─type: MemberTypeSyntax │ ├─baseType: IdentifierTypeSyntax │ │ ╰─name: identifier("Feature1") │ ├─period: period │ ╰─name: identifier("Action") ╰─rightParen: rightParen - 1 : EnumCaseElementSyntax ├─name: identifier("feature2") ╰─parameterClause: EnumCaseParameterClauseSyntax ├─leftParen: leftParen ├─parameters: EnumCaseParameterListSyntax │ ╰─[0]: EnumCaseParameterSyntax │ ├─modifiers: DeclModifierListSyntax │ ╰─type: MemberTypeSyntax │ ├─baseType: IdentifierTypeSyntax │ │ ╰─name: identifier("Feature1") │ ├─period: period │ ╰─name: identifier("Action") ╰─rightParen: rightParen
— 16:12
And this collection has all the information we need to do our job. So, let’s copy-and-paste the command we just wrote back into our macro: let elements = declaration.memberBlock.members.flatMap { $0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [] }
— 16:42
Each element in the elements collection has a name property which is the name of the case. So, we can map over the elements to turn it into a collection of strings, each one representing the property we want to add to the Cases struct. When we implemented these properties by hand, we use let properties: let casePathProperties = elements.map { element in """ let \(element.name) = CasePath( embed: { }, extract: { } ) """ }
— 17:32
And then we can interpolate that collection into the Cases struct: return [ """ struct Cases { \(raw: casePathProperties.joined(separator: "\n")) } static let cases = Cases() """ ]
— 17:48
Hopping over to tests and running again shows we are getting closer to our goal: func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) } """ } expansion: { """ enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) struct Cases { let feature1 = CasePath( embed: { }, extract: { } ) let feature2 = CasePath( embed: { }, extract: { } ) } static let cases = Cases() } """ } }
— 18:09
Of course this isn’t valid Swift code yet, but we are getting close. We just need to specify the generics on CasePath and we need to fill in the bodies of the embed and extract closures.
— 18:23
Let’s start with the generics. In order for our generated Swift code to be valid, we need to specify the Root and Value generics. The Root generic is the type that the macro is being applied to, which we can get from the declaration node.
— 18:41
But first, we have to cast it to the type we expect, which is an EnumDeclSyntax , and then we can get its name: let rootTypeName = declaration.as(EnumDeclSyntax.self)?.name
— 19:29
But let’s just make some simplifying assumptions for right now and force cast the declaration to an EnumDeclSyntax : let rootTypeName = declaration.as(EnumDeclSyntax.self)!.name
— 19:33
That means this macro will crash if we try to apply it to a non-enum type, which translates to a compilation error in your app target, but we will finesse diagnostics like that at a later time.
— 19:49
We can now interpolate this string into the property for the root generic: let \(element.name) = CasePath<\(rootTypeName), >(
— 19:54
Next we can compute the Value generic name by going through the parameters of the EnumCaseElementListSyntax.Element and plucking out its type: let valueType = element.parameterClause!.parameters.first!.type
— 20:56
Again we are going to make a huge simplifying assumption by only handling enum cases with exactly one piece of associated data. That of course will not always work in practice, and we will need to do more work here, but let’s get the basics into place first. And now we can interpolate that generic type too: """ var \(element.name): CasePath<\(rootTypeName), \(valueType)> { """
— 21:04
Let’s hop back over to tests and run the suite: func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Feature1.Action) case feature2(Feature1.Action) } """ } expansion: { """ enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) struct Cases { let feature1 = CasePath( embed: { }, extract: { } ) let feature2 = CasePath( embed: { }, extract: { } ) } static let cases = Cases() } """ } }
— 21:13
And that all looks pretty good. We have correctly figured out the Root and Value generics. However, there is a strange space after Action , and that’s because the name of the EnumDeclSyntax includes some trailing trivia, and so we can trim that like so: let rootTypeName = declaration .as(EnumDeclSyntax.self)! .name .text
— 21:33
Now the macro expansion looks a little neater.
— 21:44
Now all that is left is the embed and extract closures. The embed closure is the easiest because we can just use the name of the root type and name of the case together to form a function that is passed directly to embed : """ embed: \(rootTypeName).\(element.name), """
— 22:25
And the extract requires a little more work, but is still quite straightforward: """ extract: { guard case let .\(element.name)(value) = $0 else { return nil } return value } """
— 22:56
Running tests again shows the freshest macro expansion: func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) } """ } expansion: { """ enum Action { case feature1(Feature1.Action) case feature2(Feature2.Action) struct Cases { var feature1: CasePath<Action, Feature1.Action> { CasePath( embed: Action.feature1, extract: { guard case let .feature1(value) = $0 else { return nil } return value } ) } var feature2: CasePath<Action, Feature2.Action> { CasePath( embed: Action.feature2, extract: { guard case let .feature2(value) = $0 else { return nil } return value } ) } } static let cases = Cases() } """ } }
— 23:02
And from my point of view this is looking pretty good!
— 23:08
But also I’m no compiler. Let’s copy-and-paste the macro we are expanding in the test, but with some changes to make it actually compile: @CasePathable enum Action { case feature1(Int) case feature2(String) }
— 23:28
Well, unfortunately this does not compile. There are two errors: Declaration name ‘Cases’ is not covered by macro ’CasePathable’ Declaration name ‘cases’ is not covered by macro ’CasePathable’
— 23:30
This is a legitimate macro error, and unfortunately macro tests are not able to pick up on this error. You won’t find out about problems like this until you actually try running the macro.
— 23:42
The problem here is that macros are not allowed to freely add new members to the type it is attached to. We must describe the members the macro can add, and we do so in the signature of the macro. The simplest thing to do is tell the compiler that we could potentially add arbitrary members like so: @attached(member, names: arbitrary) public macro CasePathable() = #externalMacro( module: "CasePathsMacros", type: "CasePathableMacro" )
— 24:03
But it’s highly recommend to only use this option if you truly need to add members whose names cannot be predicted at compile time. Doing so will help the compiler and Xcode’s ability to provide good autocomplete results.
— 24:14
So it’s better to explicitly call out the members it will add. We can do this like so: @attached(member, names: named(Cases), named(cases)) public macro CasePathable() = #externalMacro( module: "CasePathsMacros", type: "CasePathableMacro" )
— 24:49
And we would hope that this would get things compiling, but instead we’re met with a pretty inscrutable error. We’re not sure if it’s a Swift bug or Xcode bug or macro bug, and it took us some time to figure out the problem, but it’s that we’re not allowed to use let s to describe the case paths in the Cases struct. So we can convert them to computed properties instead and get things building: """ var \(element.name): CasePath<\(rootTypeName), \(valueType)> { CasePath( embed: \(rootTypeName).\(element.name), extract: { guard case let .\(element.name)(value) = $0 else { return nil } return value } ) } """
— 25:33
And now we get a different error, which is a linking problem. We just need to update the test target to depend on the CasePaths module. .testTarget( name: "CasePathsMacrosTests", dependencies: [ "CasePaths", … ] )
— 25:52
And now tests are compiling. And we can even expand the macro in Xcode to see all the code that gets written.
— 26:12
We are incredibly closing to have a fully working macro. The only thing missing is that we want to further conform the type to the CasePathable protocol so that we can generically work with case paths. To do this we need to be familiar with another flavor of attached macro, called a conformance macro: @attached(extension, conformances: CasePathable) @attached(member, names: named(Cases), named(cases)) public macro CasePathable() = #externalMacro( module: "CasePathsMacros", type: "CasePathableMacro" ) This tells Swift that this macro is capable of conforming types to protocols, and we even have to be explicit in what protocols can be conformed to.
— 26:46
With that change the project is no longer compiling because Swift sees that we said our macro is now an extension macro, yet we have not conformed to the ExtensionMacro protocol: Macro implementation type ‘CasePathableMacro’ doesn’t conform to required protocol ’ExtensionMacro’
— 26:58
To do that we will hop back over to the implementation of the macro and conform CasePathableMacro to the ExtensionMacro protocol: extension CasePathableMacro: ExtensionMacro { }
— 27:03
And this protocol only has one requirement, and it is even more intense looking than the MemberMacro protocol: extension CasePathableMacro: ExtensionMacro { public static func expansion( of node: SwiftSyntax.AttributeSyntax, attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, conformingTo protocols: [SwiftSyntax.TypeSyntax], in context: some SwiftSyntaxMacros.MacroExpansionContext ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { [] } }
— 27:12
A lot of this goes very similarly to what we did for the member macro. We need to return an array of ExtensionDeclSyntax s that describe all of the conformances we want to supply to the type the macro is attached to.
— 27:31
We can do this in the simplest way possible: [ try! ExtensionDeclSyntax( """ extension \(type.trimmed): CasePathable {} """ ) ]
— 27:59
If we run the test suite one last time we will see that the extension is added at the very end of the macro expansion: func testBasics() { assertMacro { """ @CasePathable enum Action { case feature1(Int) case feature2(String) } """ } expansion: { """ enum Action { case feature1(Int) case feature2(String) struct Cases { var feature1: CasePath<Action, Int> { CasePath( embed: Action.feature1, extract: { guard case let .feature1(value) = $0 else { return nil } return value } ) } var feature2: CasePath<Action, String> { CasePath( embed: Action.feature2, extract: { guard case let .feature2(value) = $0 else { return nil } return value } ) } } static let cases = Cases() } extension Action: CasePathable { } """ } }
— 28:21
And that completes the macro! All new powers
— 28:47
We now have a very rudimentary macro that we can actually start using. Sure there are a lot of improvements that could be made. First of all we have some force unwraps in the code where we make some assumptions. Those should probably be softened a bit and instead turned into proper diagnostics that can be emitted by the macro.
— 29:04
Also we conform the type unconditionally to the CasePathable protocol, but we should omit that if we detect that the user already conformed their type to the protocol. Without that check we will generate invalid Swift code.
— 29:17
And also we have not jumped through all the hoops necessary to support all kinds of enum cases. If we have enum cases with no associated value or multiple associated values, our macro will generate invalid code. Stephen
— 29:29
However, we are not going to take the time to fix all of those problems. Don’t get us wrong, it’s important to do, and thanks to our MacroTesting library it’s even fun to do. But we have bigger fish to fry right now.
— 29:39
We would like to show how the little bit of library code we added, as well as the macro, are going to completely transform the way we interact with case paths in our libraries.
— 29:47
Let’s take a look at that now.
— 29:51
Simple case path usage
— 29:51
Lets start with the Composable Architecture to see how these new case path tools will massively improve things. I am going to even drag-and-drop my local case paths package into the Composable Architecture so that we can use all the new tools we added:
— 30:16
And already we get the ability to mark any enum with the @CasePathable macro, like say the AppFeature.Action enum: @CasePathable enum Action: Equatable { case path(StackAction<Path.State, Path.Action>) case syncUpsList(SyncUpsList.Action) }
— 30:47
Now unfortunately we can’t easily use any of the case paths generated by the macro because they are trapped in that weird case path / key path hybrid. But remember, in the future that seemingly strange hybrid type is going to be the primary definition of what a case path is. And so we will write our generic algorithms against that type instead of the CasePath type that we use today.
— 31:06
So, let’s update some of the APIs in the Composable Architecture to use the new CaseKeyPath type rather than a plain CasePath . For example, we can add another initializer to the Scope reducer that takes an action CaseKeyPath , and then can use cases to look up the case path from the key path and hand it off to another initializer in the type: extension Scope { @inlinable public init<ChildState, ChildAction>( state toChildState: WritableKeyPath< ParentState, ChildState >, action toChildAction: CaseKeyPath< ParentAction, ChildAction >, @ReducerBuilder<ChildState, ChildAction> child: () -> Child ) where ChildState == Child.State, ChildAction == Child.Action, ParentAction: CasePathable { self.init( toChildState: .keyPath(toChildState), toChildAction: ParentAction.cases[ keyPath: toChildAction ], child: child() ) } }
— 32:05
It’s a little annoying to have to maintain multiple initializers, one that takes the CasePath type and another that takes the CaseKeyPath type, but that will just be necessary in this transitional period as we deprecate the old interface and put in the new. But with that done we can go back to the AppFeature and this already works: Scope(state: \.syncUpsList, action: \.syncUpsList) { SyncUpsList() }
— 32:19
So that’s already amazing. We get type inference and autocomplete from just one small change in the app code, that of adding the @CasePathable macro to the action enum.
— 32:31
And there are tons of places where we are currently using the long form of case paths by using a forward slash, the full name of the enum type, followed by the case name. We can just search for “: /” in the project to see them all. All of these could be cleaned up with this new @CasePathable macro, but we won’t do that right now. All of them follow similarly to what we just showed.
— 32:59
Case path dynamic member lookup
— 32:59
Instead we will show off another super power. In the Composable Architecture you will often model all the destinations a feature can navigate to with an enum, such as what we see here in the SyncUpDetail feature: struct Destination: Reducer { enum State: Equatable { case alert(AlertState<Action.Alert>) case edit(SyncUpForm.State) } enum Action: Equatable, Sendable { case alert(Alert) case edit(SyncUpForm.Action) enum Alert { case confirmDeletion case continueWithoutRecording case openSettings } } var body: some ReducerOf<Self> { Scope(state: /State.edit, action: /Action.edit) { SyncUpForm() } } }
— 33:23
This is great for domain modeling because you get to have compile-time proof that only one destination can be active at a time. It is impossible for an alert and the “edit” sheet to be presented at the same time.
— 33:33
However, it does somehow complicate the alert and sheet view modifiers that we use in the view. The best API we could come up with is where you first provide the view modifier with a store that is scoped to just the domain of the destination , and then further provide two transformations to describe how to extract out a particular case from the state enum, and another transformation to describe how to embed the child domain’s actions back into the destination: .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /SyncUpDetail.Destination.State.alert, action: SyncUpDetail.Destination.Action.alert ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /SyncUpDetail.Destination.State.edit, action: SyncUpDetail.Destination.Action.edit ) { store in
— 33:59
It’s intense, and you don’t get a lot of help from type inference or autocomplete.
— 34:04
Now technically the action argument can be shortened to just this: .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /SyncUpDetail.Destination.State.alert, action: { .alert($0) } ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /SyncUpDetail.Destination.State.edit, action: { .edit($0) } ) { store in
— 34:18
So you do get the benefits of type inference and autocomplete for that argument, but then the method call looks so lopsided that we decided not to push people into that direction.
— 34:27
But, now that we have the easy construction of case paths using key path-like syntax, what if I told you we could make this syntax work: .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: \.alert, action: { .alert($0) } ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: \.edit, action: { .edit($0) } ) { store in
— 34:43
Then the 2nd two arguments in the view modifier would mimic what we do in the store scope, and we would get the benefits of type inference and autocompletion.
— 35:00
So, is this possible?
— 35:01
Well, not only is it possible, but it’s incredibly easy to accomplish. First note that the key path being provided for this argument is quite simple. It does not need to be writable. It’s just a key path that can pluck some optional SyncUpForm.State from SyncUpDetail.Destination.State : state: \.edit as KeyPath< SyncUpDetail.Destination.State, SyncUpForm.State? >,
— 35:17
Now such a key path would come for free if we had a simple get accessor defined on the SyncUpDetail.Destination.State enum: enum State { … var alert: AlertState<Action.Alert>? { guard case let .alert(value) = self else { return nil } return value } var edit: SyncUpForm.State? { guard case let .edit(value) = self else { return nil } return value } }
— 35:28
This property is perfectly fine since we are not defining the setter. Remember it’s the setter that is problematic since that forces us to come face-to-face with what it means to write nil to this property. There is no good answer for that, and so we just don’t think it should be defined.
— 35:43
And interestingly, these little getter, extractor properties are a highly requested feature in the Swift community. There have been multiple forum posts asking for them, and some of the first macro libraries to pop-up after the release of Swift 5.9 specifically targeted this problem space.
— 35:57
And so you might think since our case paths library is all about improving the ergonomics of enums that maybe our macro should just go ahead and add these properties while it’s also generating the Cases type. Well, it’s not actually even necessary. We can leverage dynamic member lookup to generate those properties for free with no additional work.
— 36:15
We can add a dynamicMember subscript to the entire CasePathable protocol that allows one to extract a value from an enum given a CaseKeyPath : extension CasePathable { public subscript<Value>( dynamicMember keyPath: CaseKeyPath<Self, Value> ) -> Value? { } }
— 36:48
This may seem weird, after all dynamicMember subscripts only work with key paths, yet here we are supplying a CaseKeyPath . How’s that possible?
— 36:56
Well, remember that CaseKeyPath is actually a type alias for a key path: public typealias CaseKeyPath<Root: CasePathable, Value> = KeyPath<Root.Cases, CasePath<Root, Value>>
— 37:00
It’s that strange case path / key path hybrid concept. So, we really are passing a key path to the subscript and that makes everything fine.
— 37:07
Then in the implementation we can simply use the key path to look up the case path in the cases static, and then use the case path to extract from self : extension CasePathable { public subscript<Value>( dynamicMember keyPath: CaseKeyPath<Self, Value> ) -> Value? { Self.cases[keyPath: keyPath].extract(from: self) } }
— 37:23
And now we can selectively opt into having getter properties for each case in an enum by applying the @CasePathable macro and applying @dynamicMemberLookup : @CasePathable @dynamicMemberLookup enum State: Equatable { case alert(AlertState<Action.Alert>) case edit(SyncUpForm.State) }
— 37:45
And with that small addition to our enum we immediately get access to computed properties for each case of the enum. And that means that this code now compiles: .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: \.edit, action: { .edit($0) } ) { store in … } .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: \.alert, action: { .alert($0) } )
— 37:56
And so this is kind of the best of all worlds. Our case paths library does give you access to computed properties for each case of your enum, but it’s an opt-in addition. That way if you don’t want to clutter your enum with properties you don’t have to. But if you do want the properties, just add on @dynamicMemberLookup .
— 38:13
SwiftUI navigation bindings
— 38:13
Now we just did something pretty incredibly, but it kind of flew by quickly so you may have missed it.
— 38:19
We have extended the concept of “dynamic member lookup” to cases of enums. That means you can define all new dynamicMember subscripts on your types that deal with CaseKeyPath s, and you will instantly unlock the ability to use regular dot-syntax chaining to dive into the cases of enums. It’s actually pretty incredible. Brandon
— 38:37
So, let’s see another example of this by looking at SwiftUI bindings, and we will even show how this power really starts to multiply when used with our SwiftUINavigation library.
— 38:48
Recall that our SwiftUINavigation library has a fun little toy app called “Inventory” to show off how to use some of the tools, and previously we looked at this piece of code: Switch(self.$item.status) { CaseLet(/Item.Status.inStock) { $quantity in Section(header: Text("In stock")) { Stepper("Quantity: \(quantity)", value: $quantity) Button("Mark as sold out") { withAnimation { self.item.status = .outOfStock(isOnBackOrder: false) } } } .transition(.opacity) } CaseLet(/Item.Status.outOfStock) { $isOnBackOrder in Section(header: Text("Out of stock")) { Toggle("Is on back order?", isOn: $isOnBackOrder) Button("Is back in stock!") { withAnimation { self.item.status = .inStock(quantity: 1) } } } .transition(.opacity) } }
— 39:02
It’s a lot, but it’s also powerful.
— 39:04
It allows us to switch over a binding of an enum, destructure each case into a binding of the associated data held in that case, and then we can hand that binding down to a Stepper or Toggle . Vanilla SwiftUI simply does not give us the tools to accomplish this very easily, and while our SwiftUINavigation library does provide the tools, they are quite verbose.
— 39:36
Well, our update to our case paths library can massively simplify this. First let’s drag and drop the case paths package into this project so that we can start using the new tools:
— 39:51
And then we will mark our Status enum as @CasePathable : @CasePathable enum Status: Equatable { case inStock(quantity: Int) case outOfStock(isOnBackOrder: Bool) … }
— 40:00
And with that done let’s see what it takes to replace the Switch view with just a simple switch : switch self.item.status { case .inStock: case .outOfStock: }
— 40:23
A perk of this is that we now get compile-time exhaustive checking that we have handled all cases, whereas previously with the Switch view the exhaustivity checking was only done at runtime.
— 40:36
If we are in the inStock case, then we can easily get a binding to the status enum: self.$item.status
— 40:48
But what we’d love to be able to do is further chain onto this binding to grab the inStock data from the enum: self.$item.status.inStock
— 40:56
This currently doesn’t compile, but it is possible to make it compile.
— 41:01
We just need to add a new dynamicMember subscript to Binding to speak the language of CaseKeyPath s. Let’s start by extending Binding specifically when the binding wraps a CasePathable type, that way we get access to cases : extension Binding where Value: CasePathable { }
— 41:19
And then we’ll add a subscript that allows you to case path into the Value in order to extract out some member from the enum: public subscript<Member>( dynamicMember keyPath: CaseKeyPath<Value, Member> ) -> <#???#> {
— 41:47
The only question: what should this subscript return?
— 42:03
There are two seemingly reasonable answers. We can either return a binding of an optional member: public subscript<Member>( dynamicMember keyPath: CaseKeyPath<Value, Member> ) -> Binding<Member?> {
— 42:08
…or an optional binding of an honest member: public subscript<Member>( dynamicMember keyPath: CaseKeyPath<Value, Member> ) -> Binding<Member>? {
— 42:10
The binding of an optional member is problematic for all the same reasons that we have outlined before. What would it mean if you could write nil to the binding? It would have no choice but to simply be a no-op, and so then why support writing an optional value at all?
— 42:28
So we will go with an optional binding as the return type: public subscript<Member>( dynamicMember keyPath: CaseKeyPath<Value, Member> ) -> Binding<Member>? { }
— 42:32
To implement this we can first construct the binding of an optional Member that we just mentioned is not what we really want: Binding<Member?>( get: { Value.cases[keyPath: keyPath] .extract(from: self.wrappedValue) }, set: { newValue, transaction in guard let newValue else { return } self.transaction(transaction).wrappedValue = Value .cases[keyPath: keyPath] .embed(newValue) } )
— 42:45
And then we can use the failable initializer on Binding that turns a binding of an optional into an optional binding: Binding<Member>( unwrapping: Binding<Member?>( get: { Value.cases[keyPath: keyPath] .extract(from: self.wrappedValue) }, set: { newValue, transaction in guard let newValue else { return } self.transaction(transaction).wrappedValue = Value .cases[keyPath: keyPath] .embed(newValue) } ) )
— 44:06
And that completes the implementation.
— 44:09
With that defined we can now write: self.$item.status.inStock
— 44:26
…in order to derive an optional binding to the quantity held in the inStock case. And we can map on that optional to get access to the underlying binding: self.$item.status.inStock.map { $quantity in }
— 44:34
And then we can copy-and-paste the section from down below into this map : self.$item.status.inStock.map { $quantity in Section(header: Text("In stock")) { Stepper("Quantity: \(quantity)", value: $quantity) Button("Mark as sold out") { withAnimation { self.item.status = .outOfStock(isOnBackOrder: false) } } } .transition(.opacity) }
— 44:40
That’s all it takes. And we can do the same for the outOfStock case: case .outOfStock: self.$item.status.outOfStock.map { $isOnBackOrder in Section(header: Text("Out of stock")) { Toggle("Is on back order?", isOn: $isOnBackOrder) Button("Is back in stock!") { withAnimation { self.item.status = .inStock(quantity: 1) } } } .transition(.opacity) }
— 45:04
This now compiles and we have been able to completely get rid of two superfluous view concepts, that of the Switch and CaseLet views, and we can write simpler, more vanilla Swift code.
— 45:39
So, that’s really cool, but also let’s go back up to the Status enum to see something a little strange: var isInStock: Bool { guard case .inStock = self else { return false } return true }
— 45:51
We have this computed property because inside the ItemRowView we want to be able to change the color of the row based on being in stock or not: .foregroundColor( self.model.item.status.isInStock ? nil : Color.gray )
— 45:59
But the computed property is basically the exact same work we are already doing in all the case paths. Why should we have to repeat it in an ad hoc manner like this?
— 46:13
Now, we could update our @CasePathable macro to automatically generate these properties, but that would just pollute our type with a bunch of properties. Luckily there’s a better way. What if we could have a single method that coalesces all case checking into a single place, allowing you to write code like this: .foregroundColor( self.model.item.status.is(\.inStock) ? nil : Color.gray )
— 46:51
That would be pretty great, right?
— 46:52
We can do this by extending CasePathable to endow all conformances with an is method that is handed a CaseKeyPath and then does the work to figure out if one can extract that case: extension CasePathable { func is<Value>( _ keyPath: CaseKeyPath<Self, Value> ) -> Bool { Self.cases[keyPath: keyPath].extract(from: self) != nil } }
— 47:47
And just like that our theoretical syntax now works, and all without polluting the enum with additional properties.
— 48:00
So we are seeing that our @CasePathable macro is empowering us to create all new kinds of tools, and more amazingly the tools are coming with very little additional work given the strong foundation we have built.
— 48:14
But we can push dynamic member look up further with case paths. If we hop over the 09-Routing.swift file we will see a lot of repetitive code like this: .alert( unwrapping: self.$destination, case: /Destination.alert ) { action in … } … .confirmationDialog( unwrapping: self.$destination, case: /Destination.confirmationDialog ) { action in … } … .navigationDestination( unwrapping: self.$destination, case: /Destination.sheet ) { $count in … } … .sheet( unwrapping: self.$destination, case: /Destination.sheet ) { $count in … }
— 48:47
These APIs mimic what we saw over in the Composable Architecture, but they are a little simpler since they do not need to worry about transforming state and actions at the same time.
— 48:58
And while these APIs are a little verbose, they are also very powerful. We have the ability to drive 4 completely different forms of navigation from just one single enum of possible destinations: enum Destination { case alert(AlertState<AlertAction>) case confirmationDialog( ConfirmationDialogState<DialogAction> ) case link(Int) case sheet(Int) }
— 49:09
We are showing an alert, confirmation dialog, drill-down and sheet from this one single enum, and we have compile-time proof that it is impossible for 2 or more of these to be presented at the same time.
— 49:19
But wouldn’t it be better if we could shorten the call sites of all of these APIs to look more like regular dot-syntax chaining? .alert(unwrapping: self.$destination.alert) { action in … } … .confirmationDialog( unwrapping: self.$destination.confirmationDialog ) { action in … } … .navigationDestination( unwrapping: self.$destination.link ) { $count in … } … .sheet(unwrapping: self.$destination.sheet) { $count in … }
— 49:41
Well, this is possible, and it just requires one more dynamicMember subscript on the Binding type: extension Binding { }
— 49:49
But this time the dynamic member will only work on bindings of optional CasePathable s. This is because in navigation one most often represents destinations as an optional enum so that nil represents not being navigated anywhere. We can define such a subscript with a little bit of Swift generic trickery: public subscript<Enum, Case>( dynamicMember keyPath: CaseKeyPath<Enum, Case> ) -> <#???#> where Value == Enum?, Enum: CasePathable { }
— 50:51
And we again have the question of what to return? A binding of an optional or an optional binding.
— 51:01
Well, this time is actually makes more sense to return a binding of an optional, whereas last time it made more sense to return an optional binding. And the reason this is the better choice now is because we are already firmly rooted in the world of optionals since we are requiring that the wrapped value in the binding is optional: where Value == Enum?, Enum: CasePathable {
— 51:23
That means there is a really sensible thing to do when one tries to write nil to the derived binding: you simply have to write nil to the binding we are deriving from .
— 51:32
And so we can implement this quite simply: extension Binding { public subscript<Member, Wrapped>( dynamicMember keyPath: CaseKeyPath<Wrapped, Member> ) -> Binding<Member?> where Value == Wrapped?, Wrapped: CasePathable { Binding<Member?>( get: { self.wrappedValue.flatMap( Wrapped.cases[keyPath: keyPath].extract(from:) ) }, set: { newValue, transaction in self.transaction(transaction) .wrappedValue = newValue.map( Wrapped.cases[keyPath: keyPath].embed ) } ) } }
— 52:47
That’s all it takes, and we almost have our theoretical syntax compiling. We just need to make sure to mark our Destination enum with the @CasePathable macro: @CasePathable enum Destination { … }
— 52:54
We don’t even need to mark it with @dynamicMemberLookup because we don’t need access to any of that functionality right now. But if in the future we do want easy getter access to cases of our enums, then we can add it.
— 53:10
And with that all of our theoretical syntax is now compiling. We now have an incredibly simple way of deriving bindings to cases of optional enums, which means driving navigation from enums is even that much simpler. Conclusion
— 53:54
So, we have completely revolutionized how one thinks about case paths in Swift. We were able to turn the concept on its head a bit by defining the fundamental unit as a strange looking key path, which allowed us to unlock key path-like syntax for case paths. And that also unlocked all types of wonderful things such as dynamic member look for the cases of enums. And also, these case key paths are naturally equatable and hashable, and it will be incredibly handy to have that functionality. Stephen
— 54:28
And the main reason we were able to do all of this is thanks to macros. We needed macros to generate properties for us inside that inner Cases data type so that we could leverage its key paths for constructing case paths. No one would have wanted to write all of that boilerplate code, and so prior to macros it was just a non-starter. Brandon
— 54:48
But now that we have this new formulation of case paths it is going to massively simplify how we use case paths in our various libraries. Be on the look out for updates to our Case Paths library, as well as closely related libraries such as the Composable Architecture and SwiftUINavigation. Stephen
— 55:03
That’s all we have for case paths right now, but next episode we embark on a whole new series to completely revolutionize the Composable Architecture.
— 55:13
Until next time! Downloads Sample code 0258-macro-case-paths-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .