Skip to content

Add StringInterpolation helpers to render errors #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 4, 2025

Conversation

finestructure
Copy link
Contributor

As discussed on Mastodon, this adds support for interpolating errors:

      @Test
      static func errorStringInterpolation() async throws {
         #expect("\(error: SomeThrowable())" == "Something failed hard.")
      }

      @Test
      static func nestedErrorStringInterpolation() async throws {
         let nestedError = DatabaseError.caught(FileError.caught(PermissionError.denied(permission: "~/Downloads/Profile.png")))
         #expect("\(error: nestedError)" == "Access to ~/Downloads/Profile.png was declined. To use this feature, please enable the permission in your device Settings.")
      }

      @Test
      static func chainedErrorStringInterpolation() async throws {
         #expect(
            "\(errorChain: SomeLocalizedError())" == """
            SomeLocalizedError [Struct]
            └─ userFriendlyMessage: "Something failed. It failed because it wanted to. Try again later."
            """
         )
      }

The argument label error: is required here or it'll break existing interpolation of Swift.Error. Alternatively (or additionally), there could be an overload

   mutating public func appendInterpolation(_ error: some Throwable) {
      appendInterpolation(ErrorKit.userFriendlyMessage(for: error))
   }

without the label but it's probably better to keep it symmetrical with errorChain:.

@Jeehut
Copy link
Member

Jeehut commented May 14, 2025

@finestructure First of all, thank you very much for the suggestion and for taking the time to implement! 👍

I like the general idea of adding string interpolation helpers to save some typing. If I understand this correctly, this is mainly a convenience addition that reduces something like this:

print(ErrorKit.userFriendlyMessage(for: error))
// or
Logger().error("Error Chain: \(ErrorKit.errorChainDescription(for: error))")

To something shorter like this:

print("\(error: error)")
// or
Logger().error("Error Chain: \(errorChain: error)")

While I'm personally not sure if this is something I would prefer (because brevity does not always lead to more readable code), I love the idea to support Swift-native features like string interpolation as long as it's optional and the developer can choose to use it if they find it more readable depending on their use case.

But I'm not sure if we should use the word error which indicates a relation to the Error protocol and which therefore might potentially be a namespace the Swift team might actually add as string interpolation at some point with some specific functionality. At least in no way indicates a relation to ErrorKit, so a developer not knowing ErrorKit might think it's some kind of baked-in feature and fail to look it up at the right place if they want to learn more.

I would much rather use some wording that is specific to ErrorKit and makes it easier to discover where it's coming from. Potential naming ideas:

  1. We could use \(throwable: error) which is a type specific to ErrorKit. But then the question arises if we should require a Throwable type to make for a consistent name.
  2. We could use \(userFriendlyMessage: error) which would be the most clear API as it directly relates to the ErrorKit.userFriendlyMessage() function being called in the underlying implementation. The downside is that userFriendlyMessage is more to type than a single word like error or throwable.

I personally would prefer approach 2, combined with \(errorChainDescription: error) for consistency. I don't know if Xcode provides auto-completion for string interpolation helpers, if it does, the downside of 2 is probably not a real downside. I haven't tested.

The end result would be an API like this:

print("\(userFriendlyMessage: error)")
// or
Logger().error("Error Chain: \(errorChainDescription: error)")

@finestructure Let me know what you think!

@finestructure
Copy link
Contributor Author

While I agree that brevity does not always lead to more readable code, I believe here it does. The extra nesting and verbosity of having to call via the full static type name make this a very "heavy" expression. In short strings, the expression is dominated by the function signature and in longer strings it'll quickly lead to awkward line breaks.

With respect to argument labels, ideally I would have proposed not to use labels at all. (As noted, this would work for Throwable.)

I think the reason it doesn't need an argument label other than to deal with overload resolution is that the interpolation essentially always does the right thing. There's nothing unsafe or unexpected about what it does, so I don't think it needs any special signalling at the call site. You have an error, it gets printed. And now in a nicer way :)

These extensions can easily be adopted client side, so please don't hesitate to close the PR if you don't feel it's a good fit!

@Jeehut
Copy link
Member

Jeehut commented Jun 4, 2025

@finestructure Thanks again for suggesting this feature. I thought about this more and agree that developers explicitly choosing to use ErrorKit probably want improved error messages everywhere they can get them. So I decided to actually make it work like this for any Error type:

print("Updated failed: \(error)")  // calls ErrorKit.userFriendlyMessage(for:) internally
print("Update failed: \(chain: error)")  // calls ErrorKit.errorChainDescription(for:) internally
print("Update failed: \(debug: error)")  // alias for chain: – therefore also calls errorChainDescription

I think this is going to be the most convenient way of printing errors and it should even automatically improve the behavior of logging just by adding ErrorKit without changing a line of code in your code base.

I'm gonna merge this PR as is so you get credited on GitHub history, gonna do the changes after. 🎉

@Jeehut Jeehut merged commit 4dee83d into FlineDev:main Jun 4, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants