[Pitch] Attached macros

My feedback after playing with an attached macro implementation: I would like to be able to add attributes and protocol conformances with a macro.

Gory details…

One feature I've long wanted in Swift is a “newtype”-like facility to make it easier to get away from primitive obsession. Since there's little or no overhead to wrapping a single value in a struct, we can ‘manually’ implement a newtype; for example:

struct Hostname: RawRepresentable,
                 Equatable,
                 Hashable,
                 Comparable,
                 Codable,
                 CustomStringConvertible
{
  var rawValue: String
  init(rawValue: String) { self.rawValue = rawValue }

  static func == (lhs: Self, rhs: Self) -> Bool { lhs.rawValue == rhs.rawValue }
  static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue }
  func hash(into hasher: inout Hasher) { rawValue.hash(into: &hasher) }
  // etc. etc. tedious conformance boilerplate
}

But it's a lot of friction to write all that out. My dream syntax is

newtype Hostname: String,
                  Equatable,
                  Hashable,
                  Comparable,
                  Codable,
                  CustomStringConvertible

One way to reduce the boilerplate is to introduce a NewTypeProtocol that refines RawRepresentable and has extensions for other protocol conformances:

public protocol NewTypeProtocol: RawRepresentable {
  init(_ rawValue: RawValue)
}

extension NewTypeProtocol {
  public init(rawValue: RawValue) { self.init(rawValue) }
}

extension NewTypeProtocol where Self: Equatable, RawValue: Equatable {
  public static func == (lhs: Self, rhs: Self) -> Bool { lhs.rawValue == rhs.rawValue }
}

extension NewTypeProtocol where Self: Comparable, RawValue: Comparable {
  public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue }
}

extension NewTypeProtocol where Self: Hashable, RawValue: Hashable {
  public func hash(into hasher: inout Hasher) { rawValue.hash(into: &hasher) }
}

// etc. etc.

This eliminates the boilerplate of the conformance requirements:

struct Hostname: NewTypeProtocol,
                 Equatable,
                 Hashable,
                 Comparable,
                 Codable,
                 CustomStringConvertible
{
  var rawValue: String
  init(_ rawValue: String) { self.rawValue = rawValue }

  // Most conformance boilerplate eliminated!
}

But the declarations of rawValue and init(_:) can't be eliminated this way.

As a workaround, Point-Free has the swift-tagged package. It still requires coming up with some extra type for each newtype to use as the tag, which can be boilerplate, and Tagged doesn't allow precise control of your newtype's conformances. I use my own variation that provides more precise control over protocol conformances than the swift-tagged package, but my scheme requires declaring a separate tag type for each newtype, which is definitely boilerplate!

So I took a stab at implementing newtype using an attached macro, declared as

@attached(member, names: arbitrary)
public macro NewType(_ type: Any.Type) = #externalMacro(module: "MacroExamplesPlugin", type: "NewTypeMacro")

and that gets my newtype declaration down to this:

@NewType(String.self)
struct Hostname: NewTypeProtocol,
                 Equatable,
                 Hashable,
                 Comparable,
                 Codable,
                 CustomStringConvertible
{ }

which is almost perfect, but:

  • The macro can't add the NewTypeProtocol conformance, so I have to declare it manually.

  • I have to specify String.self rather than just String, which is unfortunate. Maybe someday Swift will allow String by itself to mean what String.self does now. I tried to work around this by changing the declaration and use of the macro to

    @attached(member, names: arbitrary)
    public macro NewType<T>(_: T.Type = T.self) = #externalMacro(module: "MacroExamplesPlugin", type: "NewTypeMacro")
      
    @NewType<String>()
    struct Hostname: NewTypeProtocol,
      etc. etc.
    

    but swiftc crashed.

  • It would be nice, if vending a resilient library, to be able to add @frozen to the newtype declaration.

My macro implementation:

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public enum NewTypeMacro { }

extension NewTypeMacro: MemberMacro {
  public static func expansion<Declaration, Context>(
    of node: AttributeSyntax,
    providingMembersOf declaration: Declaration,
    in context: Context
  ) throws -> [DeclSyntax] where Declaration : DeclGroupSyntax, Context : MacroExpansionContext {
    do {
      guard
        case .argumentList(let arguments) = node.argument,
        arguments.count == 1,
        let memberAccessExn = arguments.first?
          .expression.as(MemberAccessExprSyntax.self),
        let rawType = memberAccessExn.base?.as(IdentifierExprSyntax.self)
      else {
        throw CustomError.message(#"@NewType requires the raw type as an argument, in the form "RawType.self"."#)
      }

      guard let declaration = declaration.as(StructDeclSyntax.self) else {
        throw CustomError.message("@NewType can only be applied to a struct declarations.")
      }

      let access = declaration.modifiers?.first(where: \.isNeededAccessLevelModifier)

      let decls = ("""
        {
          \(access)typealias RawValue = \(rawType)

          \(access)var rawValue: RawValue

          \(access)init(_ rawValue: RawValue) { self.rawValue = rawValue }
        }
        """ as MemberDeclBlockSyntax).members.map(\.decl)

      return decls
    } catch {
      print("--------------- throwing \(error)")
      throw error
    }
  }
}

extension DeclModifierSyntax {
  fileprivate var isNeededAccessLevelModifier: Bool {
    switch self.name.tokenKind {
    case .keyword(.public): return true
    default: return false
    }
  }
}

extension SyntaxStringInterpolation {
  // It would be nice for SwiftSyntaxBuilder to provide this out-of-the-box.
  fileprivate mutating func appendInterpolation<Node: SyntaxProtocol>(_ node: Node?) {
    if let node {
      appendInterpolation(node)
    }
  }
}
4 Likes