Open Structs for DTO Types

For data that needs to be serialized I often write code like this:

public struct MyType: Codable, Sendable {
    public var id: UUID
    public var createdAt: Date
    public var title: String
    public var description: String
    public var items: [Int]

    public init(
        id: UUID,
        createdAt: Date,
        title: String,
        description: String,
        items: [Int]
    ) {
        self.id = id
        self.createdAt = createdAt
        self.title = title
        self.description = description
        self.items = items
    }
}

These types must often be public because they are part of a package or another target. There are two redundancies that are quite annoying:

  1. public must be repeated for each property.
  2. The initializer repeats each field type once, and each field name three times.

What do you think of re-using the open keyword for structs to simplify this? The rules would be:

  • All stored properties are automatically public, visibility modifiers are not allowed for stored properties
  • The memberwise initializer is always available and public

The example would look like this then:

open struct MyType: Codable, Sendable {
    var id: UUID
    var createdAt: Date
    var title: String
    var description: String
    var items: [Int]
}
1 Like

Not sure about overloading the open keyword, but empathic to the general idea.

Also, I often find myself creating structs with a standard set of base members, such as id, createdAt, updatedAt, createdBy or similar. It would be nice to have a light-weight syntax for creating structs that just "extends" another. Or perhaps a kind of "template" struct? Idk.

You can handle all of this using macros, without the need to introduce keywords ambiguity. You can write your own (for this cases it is pretty simple to implement) or use some ready solution that covers the need. You will still have to repeat public, but that's universal for Swift - type members does not inherit its visibility implicitly.

Maybe you can create a @DTO macro to decorate your struct, and maybe the macro could even have an optional set of "standard" vars be auto-generated.Your macro could synthesize inits, Codable etc.

2 Likes

Is it possible to make fields public with macros?

Don’t Macros currently add a lot of time to build times because of Swift-syntax?

2 Likes

You can mitigate that by using precompiled binary once you sure macros does what you need. This is something you are not expecting to change a lot anyway

Not sure, since I have tried only early versions and it has changed since then, but you can try and find out fairly quickly. I maybe will give it a try as well, that is an interesting case

I think you can achieve that with additional type:

@DTO
public struct MyType {
    private struct Blueprint {
        var id: UUID
        var createdAt: Date
        var title: String
        var description: String
        var items: [Int]
    }
}

so the macro will expand (roughly) like the following:

public struct MyType: Codable, Sendable {
    private struct Blueprint {
        var id: UUID
        var createdAt: Date
        var title: String
        var description: String
        var items: [Int]
    }
    
    public var id: UUID
    public var createdAt: Date
    public var title: String
    public var description: String
    public var items: [Int]

    public init(
        id: UUID,
        createdAt: Date,
        title: String,
        description: String,
        items: [Int]
    ) {
        self.id = id
        self.createdAt = createdAt
        self.title = title
        self.description = description
        self.items = items
    }
}

So you actually generate you public struct with the bluepring/spec of desired fields.

EDIT: @lassejansen that's working so far:

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

extension DeclModifierListSyntax.Element {
    private static let visibility = [.public, .private, .internal, .open, .fileprivate] as [Keyword]

    var isVisibilityModifier: Bool {
        if case let .keyword(val) = self.as(DeclModifierSyntax.self)?.name.tokenKind {
            return Self.visibility.contains(val)
        }
        return false
    }
}

public struct DTOMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let modifier = declaration.modifiers.first(where: { m in
            m.isVisibilityModifier
        }).map({ DeclModifierSyntax(name: $0.name.trimmed) }) else {
            return []
        }
        let blueprint = declaration.memberBlock.members.first { m in
            m.decl.is(StructDeclSyntax.self)
        }?.decl.as(StructDeclSyntax.self)
        var bpMembers = blueprint?.memberBlock.members.compactMap { m -> VariableDeclSyntax? in
            m.decl.as(VariableDeclSyntax.self)
        } ?? []
        for i in bpMembers.indices {
            if let mi = bpMembers[i].modifiers.firstIndex(where: { m in
                m.isVisibilityModifier
            }) {
                bpMembers[i].modifiers[mi] = modifier
            } else {
                bpMembers[i].modifiers.append(modifier)
            }
        }
        return bpMembers.map {
            DeclSyntax(fromProtocol: $0)
        }
    }
}

@main
struct structsPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        DTOMacro.self,
    ]
}
1 Like

I wonder why the explicit initializer is needed at all. Will it not just be synthesized by the compiler?

Compiler synthesized memberwise initialisers are internal to avoid accidentally publicising the ability to create the struct outside the module. This is a safety mechanism so you have to opt in to being able to publicly construct the type, which is a capability you may not want to expose.

7 Likes

And this has been the subject of various past discussions, around how to better handle this (e.g. the ability to say public init = default to get a publicly-available synthesised). I don't recall a formal proposal ever being tendered, though.

1 Like

…but that was a long time ago, and was only returned for revision, not outright rejected.

2 Likes

this is not possible on linux as far as i am aware

1 Like

Unfortunately "opting in" means spelling out the entire initializer :sweat_smile:

I think declaring a struct as open would transport the meaning of opting out of this safety mechanism quite well.

That's a complication :sweat_smile:

Well, with macros the public init synthesis macro can become a part of stdlib in that case. It shouldn't be working on declaring properties public, though, I believe.