ST-0011: Issue Handling Traits

Hello Swift community,

The review of ST-0011 "Issue Handling Traits" begins now and runs through Wednesday July 9, 2025. The proposal is available here:

https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0011-issue-handling-traits.md

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

Trying it out

To try this feature out, add a dependency to the main branch of
swift-testing to your package:

dependencies: [
  ...
  .package(url: "https://github.com/swiftlang/swift-testing.git", branch: "main"),
]

Then, add a target dependency to your test target:

.testTarget(
  ...
  dependencies: [
    ...
    .product(name: "Testing", package: "swift-testing"),
  ]

Finally, import Swift Testing using @_spi(Experimental) import Testing.

What goes into a review?

The goal of the review process is to improve the proposal under review
through constructive criticism and, eventually, determine the direction of
Swift. When writing your review, here are some questions you might want to
answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

swift-evolution/process.md at main · swiftlang/swift-evolution · GitHub

Thank you for contributing to Swift!

Paul LeMarquand

Review Manager

5 Likes

A couple of notes:

Concretely, this policy means that issues for which the value of the kind property is .system will not be passed to the closure of an issue handling trait. Similarly, it is not supported for a closure passed to compactMapIssues(_:) to return an issue for which the value of kind is.system.

I'd like to include .apiMisused in this constraint, at least for the moment. We can relax that restriction in the future if we think it's necessary, but .apiMisused is currently reserved for misuses of Swift Testing's APIs, so it falls into the same general category as .system. (Again, this doesn't mean we can't adjust the policy later, but once it's allowed it will be very difficult to disallow it.)

  • While this means that issue handlers cannot directly perform asynchronous work when processing an individual issue, future enhancements could offer alternative mechanisms for asynchronous issue processing work at the end of a test. See the Future directions section for more discussion about this.

Might be worth calling out Task.immediate {} here as a possible workaround/solution.

@Test(.compactMapIssues { issue in
  var issue = issue
  issue.comments.append("Checking whether two literals are equal")
  return issue
})

Did you consider any alternatives to the trailing closure syntax here? I don't have anything specific in mind, just asking.

1 Like

The proposal rules out passing issues as inout like this:

However, in order to suppress an issue, the parameter would also need to become optional (inout Issue? ) and this would mean that all usages would first need to be unwrapped. This feels non-ergonomic, and would differ from the standard library's typical pattern for compactMap functions.

But this is not the only option. We could make the closure return Void? instead and have the parameter remain inout Issue. With this change, all the sample code from the proposal would remain the same, save for the shadowing of the parameter:

@Test(.compactMapIssues { issue in
-  var issue = issue
   issue.comments.append("Checking whether two literals are equal")
-  return issue
})

Importantly, you can still return nil to filter out issues:

@Test(.compactMapIssues { issue in
  guard !SensitiveTerms.all.contains(where: { description.contains($0) }) else {
    return nil
  }
  issue.comments.append("Not sensitive")
})

With this change I could also see one of the simpler names like transformIssues or handleIssues making a comeback.

1 Like

This is an interesting (ab)use of the type system, but it isn't a pattern I've seen elsewhere in Swift, in particular in the standard library. I would be hesitant to adopt it without prior art, and I would be worried that changes to the compiler could cause it to fail to build in the future (i.e. if Void? starts getting treated less like Void and more like other specializations of Optional).

I wouldn’t call it abuse and there’s no risk as this pattern relies solely on the documented behavior of 1) the compiler inserting an implicit return () and 2) the compiler implicitly promoting values to optionals when possible. Neither of those features could realistically ever change, certainly not without a new language mode. So the question is only about whether this is the right shape for the API at hand. I’m sympathetic to the concern that this is unprecedented in the standard library (though neither is compactMap really a thing outside of the Sequence and Collection APIs).

To clarify, the implicit return is documented for Void, but not (to my knowledge) for Optional<Void>. While the two types are semantically related, one is not always substitutable for the other, and I'm not sure if this particular scenario is intentional or emergent behaviour.

I did a very haphazard search of the Swift repo and didn't see anything directly relevant (there's one SIL-level file that talks about return being shorthand for return () but that's about it I think.) I'm happy to be wrong about this, of course. :face_without_mouth:

I like the spirit of this idea - reducing the boilerplate - though I agree with @grynspan that returning Optional<Void> is extremely weird and maybe not future-proof. The only other (common) place for something like this is in failable initializers, and it feels weird to see this pattern outside of that context.


I find myself wanting a more functional approach to this. With a lens, you could write this as:

func lens<Root, SubType>(_ root: Root, _ keyPath: WritableKeyPath<Root, SubType>, closure: (SubType) -> SubType) -> Root {
    var root = root
    let subtype = root[keyPath: keyPath]
    let modifiedSubtype = closure(subtype)
    root[keyPath: keyPath] = modifiedSubtype
    return root
}

@Test(.compactMapIssues { issue in
    // I'm sure there's some third-party library that provides cleaner syntax for this.
    // this is just what I came up with in ~5 minutes.
    lens(issue, \.comments) { $0.appending("Some comment") }
})

Which works, but is ugly. I wish Swift had some dedicated support for lenses to clean that up. But that's a separate proposal I don't want to take on right now.


Overall, I think this is a great feature, and I'm looking forward to making use of it.

1 Like

Thanks for the review feedback so far, everyone! I'm going to try and reply to several things below:

Sure, I'll add .apiMisused to the implementation and proposal.

I could, although I worry it might give too much of a false sense of capability. I suspect most uses would involve using an unstructured task to obtain some value and then modifying the issue's comments (or someday, adding an attachment), and AFAIK that wouldn't be possible when using Task.immediate. I'm not sure how it would differ from plain Task { … } in this regard.

I'm not strongly against mentioning it in passing in the proposal, but I'm inclined to skip doing so to avoid confusion.

I did include one usage example in the proposal of having a "shared" issue handling trait vended by a static var in an extension on Trait — see ignoreSensitiveWarnings. My proposed APIs don't strictly require the use of trailing closure syntax to pass the transformation function, it's just the style I showcased in most examples.

That's a very interesting idea, @Val. I admit I was very tempted and interested when I first read it, and I acknowledge it would allow choosing a different, arguably better, name like transformIssues as my original draft had.

But I ultimately came to the same conclusion as @grynspan and feel adopting an uncommon API pattern such as this would be risky and non-obvious to users, despite having the ergonomic benefits of a non-optional inout parameter. I could definitely be convinced, though, especially if someone authoritative who works on the language, type-checker, or compiler could weigh in and guarantee this pattern will continue to work. Beyond the concern that it might outright break in future compilers or language versions, I also worry that using an unfamiliar API style might be confusing to read or might cause casual users to not realize they can return nil. Although the current compactMapIssues { … } style can be more verbose, it's a pattern many Swift users are familiar with.

Anyone who finds the inout pattern nicer could easily build upon the proposed .compactMapIssues { … } API and write their own utility using the Void? pattern you suggest in their own code. I'm just unsure whether it's suitable to be part of the official API.

I'm still open to further opinions on this topic, though! And in the mean time I'll add this to Alternatives Considered.

That's a cool example for sure. I'm not too familiar with the "lens" pattern in general but it does feel like a general capability that could compose well with this proposal—but only if the API adheres to common patterns, which may be another vote against the (clever) Void? suggestion above.

After further consideration, I think .apiMisused issues should still be passed to an issue handling trait. But I think the restriction we should add is that an issue handling trait closure must not return an issue for which the value of kind is .apiMisused unless it originally had that kind when passed in.

The general policy I'm proposing is that an issue handler should only be passed issues which were recorded in response to actions taken by the test, per code written by the test author. An .apiMisused issue can be recorded in response to the actions of a test. However it would not be sound for an issue handling trait closure to be passed an ordinary issue and reassign its kind to .system or .apiMisused before returning it, so I think that should be disallowed.

In summary, I think the rules should be:

  • The closure of an issue handler trait will never be passed an issue for which the value of kind is .system.
  • The closure of an issue handler trait must never return an issue for which the value of kind is .system or .apiMisused.

Can you describe a scenario where it'd be useful to pass an .apiMisused issue to this API? I imagine you're thinking of allowing suppression of such issues—if so, why is that valuable? (Not arguing, just getting more detail.)

I wasn't so much thinking about suppressing such issues, but rather augmenting them with more info, perhaps, or just taking some unrelated action.

We don't have very many actual uses of .apiMisused in the testing library today, so this is mostly a hypothetical consideration right now — it's more about what the documented behavior should be. And since we document .apiMisused as something that happens when the testing library is mis-used, it stands to reason for me that it ought to be possible to react to such misuses if your code caused them in the first place.

We could consider a middle ground here where we disallow suppressing .apiMisused issues (by e.g. returning nil), but still pass them to the closure, and then consider relaxing that policy in the future. I mainly want it to be possible for someone to receive and be informed about such issues—and more broadly, about any issues they caused.

While I'm very excited about the idea to customize issue handling, I'm still not sure that an inline closure inside a @Test macro declaration is the best way.

For my use case, I'm going to run specific blocks with custom handling, instead of doing it per test. Having a trait that customizes behavior is definitely a good idea, but I would want to promote re-using this behavior as much as possible, e.g. by making my own Trait that provides a scope.

1 Like

I absolutely agree. It's often better for code organization to factor out the body of an issue handling closure to some shared location, both to improve readability of individual tests and to allow sharing the handler between multiple tests. In the proposal I showcased one example of how to do that (repeated below) and I definitely recommend this pattern:

extension Trait where Self == IssueHandlingTrait {
  static var ignoreSensitiveWarnings: Self {
    .filterIssues { issue in
      let description = String(describing: issue)

      // Note: 'Issue.severity' has been pitched but not accepted.
      return issue.severity <= .warning && SensitiveTerms.all.contains { description.contains($0) }
    }
  }
}

@Test(.ignoreSensitiveWarnings) func exampleA() {
  ...
}
@Test(.ignoreSensitiveWarnings) func exampleB() {
  ...
}

Notice how you don't have to create your own custom scoping trait to accomplish this pattern. This technique still initializes an instance of the IssueHandlingTrait type from this proposal.

ST-0011 has been accepted. Thanks for participating!

Paul LeMarquand
Review Manager

4 Likes