How do noncopyable types and consuming methods make an API hard to misuse: transactions, tokens and one-shot handles?

15.10.2 · L5 · Interview

Predict first

A transaction is committed on one path and dropped on the other:

struct Transaction: ~Copyable {
    let id: Int
    init(_ id: Int) { self.id = id; print("begin \(id)") }
    consuming func commit() { print("commit \(id)"); discard self }
    deinit { print("rollback \(id)") }
}
func transfer(succeeds: Bool) {
    let t = Transaction(succeeds ? 1 : 2)
    if succeeds { t.commit() }
    print("end of transfer")
}
transfer(succeeds: true)
transfer(succeeds: false)
  • begin 1, commit 1, end of transfer, begin 2, rollback 2, end of transfer
  • begin 1, commit 1, rollback 1, ...
  • The program does not compile

Predict first

Calling commit twice on the same transaction:

struct Transaction: ~Copyable {
    consuming func commit() { discard self }
    deinit { }
}
func run() {
    let t = Transaction()
    t.commit()
    t.commit()
}
run()
  • It does not compile: the value is consumed more than once
  • It commits twice
  • It crashes at run time

Mental model. A single-use train ticket: once the gate takes it, you cannot go through again with the same ticket. If you never use it, it quietly expires instead of staying valid.

Some values must be used exactly once: a transaction (commit or roll back), a continuation (resume once), a file handle (close once), a one-time token. With ordinary copyable types the API can only document that, or check at run time. A ~Copyable struct (SE-0390) cannot be copied, and a consuming method (SE-0377) takes ownership, so after t.commit() the compiler rejects any further use of t: “'t' consumed more than once” (the second check). A deinit on the noncopyable type then expresses the default fate: in the check transfer(succeeds: true) prints “commit 1”, while the path that never commits prints “rollback 2” when the value goes out of scope, which is the safe default for an abandoned transaction. discard self inside commit stops the deinit running after the consuming method has finished the job (it is allowed only when the remaining stored properties are trivially destroyed in this compiler version, which is why the check stores an Int id rather than a String).

Where it fits in API design: builder-style “finish” methods (consuming func build()), handles to scarce resources, and sequences of states where each step consumes the previous one. Limits to state in an interview: generics over noncopyable types and protocol conformances are still restricted compared to copyable types (support has been widening each release, so check your minimum compiler), existential and Objective-C interop are limited, global variables cannot be consumed, and the compile-time guarantee holds only for code the compiler can see: a public API in a library built with library evolution needs @frozen-style care. Use it when misuse is costly and the type is naturally unique, and keep a copyable alternative for APIs where most callers do not need the strictness. Q50 and Q155 cover the ownership vocabulary.

discard self suppresses the deinit after commit; an abandoned value rolls back at scope end.

Ownership is checked at compile time.

Common traps

  • Forgetting discard self in a consuming finish method. The deinit then also runs and the rollback fires after the commit.
  • Making the common case awkward. If most callers copy the value, a noncopyable type only gets in their way.

They might follow up with

  • How is this different from a class with an isFinished flag? The flag is a run-time check; the noncopyable type is a compile-time one.
  • Can I store it in an array? Support for noncopyable elements in standard collections is limited; check your toolchain.

Related questions

  • 15.10.1 How do phantom-typed identifiers stop an order id being passed where a user id is expected, and what do they…
  • 1.8.5 What are consume, borrowing, and noncopyable types, at a high level?
  • 3.6.2 What do guaranteed and owned parameters mean, and what do borrowing and consuming add?
  • 15.5.1 What makes a good Swift API: naming, argument labels, defaults, ownership, thread documentation and misuse…

← How do phantom-typed identifiers stop an order id being passed where…   How do you design the error type of a library: stable cases… →