Automatic Struct to string? For Text(someStruct)

Hi,
I don't really know my way around swift yet.
I have a string that has some rules, so I wrapped it in a struct:

// for example
struct Name {
    var name: String = ""

    init?(_ name: String) {
        // if passes some rules
        self.name = name
    }
}

struct Person {
    var name: Name
}

Then later I want to print this in swiftui:
Text(person.name)

But obviously that doesn't work. You have to do:
Text(person.name.name)
Which just bugs me.

The only thing I've been able to find is CustomStringConvertible, but that doesn't do this.

Is there a protocol thing that does what I want?
Should I not be wrapping things this way?

1 Like
  1. For the Swift language side of things, you could write an extension on Text:

    extension Text {
      init(_ name: Name) {
        self.init(name.name)
      }
    }
    

    You might want to create a protocol if this is a common thing. You might think CustomStringConvertible is that protocol, but read on...

  2. For the actual example that you've given: formatting names for display is surprisingly difficult! You might need to consider all kinds of cultural details. Foundation's PersonNameComponentsFormatter handles that for you.

    This is why CustomStringConvertible is perhaps not the protocol to use for this - the string it produces may not be appropriate for a UI. I don't believe there is a protocol for that (LocalizedCustomStringConvertible?), and that you are advised to try Foundation's formatting APIs. But within your own project, do what is most comfortable for you :)

2 Likes

Thanks, Karl. I think that makes sense.
Name was just for the example. Not necessarily a person's name. Maybe I shouldn't have used Person as the example container!

I have many different types of these... types. So I'd have to extend Text for each one. Hrm.
With some quick tests, seems like I can do something like this:

protocol Textable {
    var value: String { get }
}

extension Text {
    init(_ val: Textable) {
        self.init(val.value)
    }
}

Mostly curious what other people do!

1 Like

I think Name is a great example! It shows the kinds of things you need to consider - yes, CustomStringConvertible exists and lots of things already conform to it, but there are benefits to defining a new thing, like you've done with Textable.

Since you're using Textable.value for a SwiftUI Text label, this protocol is specifically for UI-appropriate strings.

From the Swift language level, there isn't a whole lot of difference between Textable.value and CustomStringConvertible.description, but for structure and maintainability, separating these two concepts is a sensible design, IMO. I'd personally be happy with something like Textable (or whatever you want to call it) :+1:

There's also room for expansion - perhaps with a variant offering various styles (.compact, .long, etc). That could be offered as a derived protocol or a requirement with default implementation.

1 Like

Just to give an alternate design suggestion here, the way I'd probably approach this is to rename the name property to something more neutral like 'value'. So then you'd have Text(person.name.value) in the worst case. That gives you no more akward repetition of 'name'.

Then, with a CustomStringConvertible conformance like var description: String { self.value }, you can take advantage of string interpolation to write Text("\(person.name)")`` or Text("My name is \(person.name)") when you need to present it in a human-readable form.

IMO the protocol convenience should only really be introduced if you are finding yourself you need to do that pattern of Text(person.name) very frequently. Otherwise, it doesn't really hold its weight. and supporting interpolation is more composable and widely reusable.

As with most things in API design, a huge factor here is stylistic subjective decisions. You gradually learn an intuition as to what is 'right' for a particular situation.

2 Likes

That makes sense. Thanks. I was changing everything to .value when I saw this.

It still doesn't like Text("\($0.name)")

No exact matches in call to instance method 'appendInterpolation'

name.description and name.value are ok.

You can provide a computed property on Person called name and rename the other one to _name (or something else) and make it private. Inside the getter for name, you can call _name.name.

Then at use site you can do person.name.

struct Person {
  private let _name: Name

  var name: String {
    _name.name
  }

  init(name: Name) {
    self._name = name
  }
}

If you have other properties you want to access on Name then it might be tedious to have to declare a computed properly for each. In that case, you can use @dynamicMemberLookup instead to simplify the implementation.

@dynamicMemberLookup
struct Person {
  private let _name: Name

  init(name: Name) {
    self._name = name
  }

  subscript<T>(dynamicMember member: KeyPath<Name, T>) -> T {
    _name[keyPath: member]
  }
}

This will allow you to access any non-private properly on Name directly. So for example you can do person.name.lastName instead of having to do person.name.name.lastName

Yet another solution could be to use Swift’s callAsFunction trick to make accessing the underlying value even more succinct:

struct Name {
  var value: String
  
  func callAsFunction() -> String {
    // Alternatively, define conditional logic here and have it computed on each access.
    value
  }
}

struct Person {
  var name: Name
}

Call site:

let container: Name = person.name
let name: String = person.name()
Text(person.name())

I would assume callAsFunction could be vended as an extension to a protocol as well, to avoid defining it on numerous such container types.

I believe you can still use string interpolations if you use a custom protocol. You don't need CustomStringConvertible for that.

protocol Textable {
  func text(style: FormatStyle) -> String
}

enum FormatStyle {
  case compact
  case regular
  case long
}

// Is there a better thing to extend than DefaultStringInterpolation?
extension DefaultStringInterpolation {
  mutating func appendInterpolation(_ value: Textable, style: FormatStyle = .regular) {
    self.appendInterpolation(value.text(style: style))
  }
}

struct Foo: Textable {

  func text(style: FormatStyle) -> String {
    switch style {
    case .compact:
      return "F/t"
    case .regular:
      return "Foo.text"
    case .long:
      return "Foo.text(style: FormatStyle) -> String"
    }
  }
}
let aFoo = Foo()
print("Hello, \(aFoo)")  // "Hello, Foo.text"
print("Hello, \(aFoo, style: .compact)")  // "Hello, F/t"