Introducing Namespacing for Common Swift Error Scenarios

Introducing Namespacing for Common Swift Error Scenarios

  • Proposal: SE-00xx
  • Authors: Dave DeLong
  • Review Manager: TBD
  • Status: TBD
  • Decision notes: TBD
  • Implementation: TBD
  • Discussion threads: TBD
  • Review thread: TBD

Introduction

This proposal introduces namespacing for fatal errors. It provides an umbrella for common exit scenarios, modernizing a holdover from C-like languages.

Motivation

Swift's fatalError is arguably insufficient for a multitude of real world uses including discoverability, extensibility, and significance. Swift lacks a modern extensible solution that differentiates fatal error scenarios. Too many developer libraries include variations of the following custom utility, which adds an annotated unimplemented exit point to code:

/// Handles unimplemented functionality with site-specific information
/// courtesy of Soroush Khanlou, who discovered it from Caleb Davenport, 
/// who discovered it from Brian King
func unimplemented(_ function: String = #function, _ file: String = #file) -> Never {
    fatalError("\(function) in \(file) has not been implemented")
}

Free-standing functions (including fatalError) are not easily discoverable to programmers new to Swift. By namespacing marker functions to a common type, they become groupable, extensible, and more discoverable in the IDE. This allows users to autocomplete and jump-to-definition.

Proposed solution

Introduce Fatal, a public struct with static error members. The precise wording and choice of each error message can be bikeshedded after acceptance of this proposal's namespacing concept.

/// An umbrella type supplying static members to handle common
/// and conventional exit scenarios.
public enum Fatal {
    
    /// Die because a default method must be overriden by a 
    /// subtype or extension.
    public static func mustOverride(function: StaticString = #function, file: StaticString = #file, line: UInt = #line) -> Never {
        die(reason: "Must be overridden", extra: String(describing: function), file: file, line: line)
    }

    /// Die because this code branch should be unreachable
    public static func unreachable(_ why: String, file: StaticString = #file, line: UInt = #line) -> Never {
        die(reason: "Unreachable", extra: why, file: file, line: line)
    }

    /// Die because this method or function has not yet been implemented.
    /// 
    /// - Note: This name takes precedence over `unimplemented` as it
    ///   is clearer and more Swifty
    public static func notImplemented(_ why: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never {
        die(reason: "Not Implemented", extra: why, file: file, line: line)
    }
    
    /// Die because of a failed assertion. This does not distinguish
    /// between logic conditions (as in `assert`) and user calling 
    /// requirements (as in `precondition`)
    public static func require(_ why: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never {
        die(reason: "Assertion failed", extra: why, file: file, line: line)
    }

    /// Die because this TODO item has not yet been implemented.
    public static func TODO(_ reason: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never {
        die(reason: "Not yet implemented", extra: reason, file: file, line: line)
    }

    /// Provide a `Fatal.error` equivalent to fatalError() to move
    /// Swift standard style away from using `fatalError` directly
    public static func error(_ reason: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never {
        die(reason: "", extra: reason, file: file, line: line)
    }

    /// Performs a diagnostic fatal error with reason and 
    /// context information.
    private static func die(reason: String, extra: String?, file: StaticString, line: UInt) -> Never {
        var message = reason
        if let extra = extra {
            message += ": \(extra)"
        }
        fatalError(message, file: file, line: line)
    }

}

Source compatibility

This proposal is strictly additive. There is no reason to deprecate or remove fatalError.

Effect on ABI stability

This proposal does not affect ABI stability.

Effect on API resilience

This proposal does not affect API resilience.

Alternatives considered

  • Naming alternatives include Die, Crash, Require, Abort, Halt. These alternatives, like Fatal, are chosen for their readability in source.

  • Using Never as a namespace, such as Never.reachable, Never.TODO, Never.sayDie, Never.sayNeverAgain, etc. This is slightly less readable but avoids adding a new type.

    /// The return type of functions that do not return normally; a type with no
    /// values.
    ///
    /// Use `Never` as the return type when declaring a closure, function, or
    /// method that unconditionally throws an error, traps, or otherwise does
    /// not terminate.
    ///
    ///     func crashAndBurn() -> Never {
    ///         fatalError("Something very, very bad happened")
    ///     }
    public enum Never {
    }
    
  • In terms of practical utility but limited dignity, the following may be of use:

    extension Fatal {
        /// Silence Xcode requirements (like init(coder:)) 
        /// by adding functionality that should never be called.
        public static func silenceXcode(function: StaticString = #function, file: StaticString = #file, line: UInt = #line) -> Never {
            die(reason: "Xcode requires unimplemented \(#function)", extra: nil, file: file, line: line)
        }
    }
    
16 Likes

Respectfully, Iā€™m not a big fan of this since itā€™s not extendable. For example, in projects I've worked on it was common to have some similar things that log the errors and then call fatalError. In my opinion this is best left up to the app developer instead of a language feature.

It is easy to simply use an approach similar to the Soroush/Caleb/Brian function and customize to your needs. I feel like this isn't something the standard library should have an opinion about.

1 Like

I think this is a useful feature. Namespacing fatalError and providing other, more descriptive crashers seems like a worthy goal.

4 Likes

This proposal makes a lot of sense, I like it. One thing I'm unclear on is the distinction between notImplemented and TODO ?

notImplemented would be for something you have to put in but have no intention of supporting. The "silenceXcode" suggestion is a special case of notImplemented.

On the other hand, TODO would be something you intend to implement, but haven't gotten to yet.

If this is the underlying motivation, then the solution would be better discoverability for free functions. It cannot be to remove free functions piecemeal from the standard library.


In general, I think this functionality can indeed make lots of sense: making error messages more expressive is definitely good. However, it doesn't seem like it needs to be in the standard library at all; in fact, I'd argue that it's probably the least ideal place to put these functions:

Since the other motivation stated is that developers should be able to express their exact needs, then as @soffes says, it should be left to those developers to customize, not to expand one function to five. The standard library, by definition, has the least flexibility in terms of evolving over time to accommodate custom needs.

2 Likes

Then I would prefer something like notSupported and notImplementedYet

Btw, I love it. Only thing is that I'd like the namespace to be created with an enum instead of a struct

2 Likes

If Swift had an "abstract" keyword we would not need this, as the condition would be checked at compile time. I do not understand the reluctance to add "abstract". There are legitimate uses of abstract methods. Even if one wants to deny this, there are certainly many existing APIs with abstract methods that developers may want to wrap or emulate in Swift.

3 Likes

Abstract can usually be simulated with default protocol implementations, can it not? Is there something that they can't do that you need?

Yes, be abstract ā€“ i.e. cause child classes that do not implement them to fail to compile, not silently no-op or crash at runtime. Any runtime error that can be converted to a compilation failure is a win for the language.

3 Likes

Yes, that's what default implementations do:

protocol Foo {
	func f1()
	func f2()
}

extension Foo {
	func f1() {
		print("Default implementation of f1")
	}
}

struct Bar: Foo {} // error: type 'Bar' does not conform to protocol 'Foo'

struct Baz: Foo { // valid
	func f2() {
		print("Implementation of f2")
	}
}

If you fail to implement something, this code will fail to compile.

Using protocol extensions for this purpose has problems:

This is simply not an acceptable replacement as far as I am concerned.

1 Like

Personally, I don't see much difference between using struct vs enum as the namespace, but since we're already doing that (essentially) with Never, I'm fine with that. I'll edit the original post to reflect this change.

Thanks!

The only real difference is being able to instantiate Fatal() or not (without having to define a private init())

I don't follow.

extension Fatal {
	func mySpecialError(file: StaticString, line: UInt) -> Never {
		die("something bad", file: file, line: line)
	}
}

Seems a lot better to me then the top-level function approach because devs new to your module will immediately know where to look for custom fatal errors.

6 Likes

Imho enum is preferable, but itā€˜s still misuse:
If we need namespaces, we should add them.

4 Likes

Hey @davedelong, what do you think about just using Never as the core type and providing a public typealias of Fatal = Never. Sure people will then be free to use Never.unreachable but then again people are also free to lick Tide pods. Neither is a good idea.

This could let you build off an existing type, with the proper semantics, but avoid the unfortunate Never core name that seems to rebut its static methods. This way you still get Fatal.unreachable("reason") , etc., with a minimum of stdlib turbulence.

This seems to be going down the route that some would call DBC (Design By Contract) coined by Eiffel language. I previously worked at a company that had its own implementation of a DBC framework in Swift to define those contracts by using similar fatalError messages in Debug builds. But some would not crash when built in release. There seems like there are too many cases where th behavior of functions like these would need to be specific to a teams needs and would be a better fit as a dependent library instead. Examples of how we defined them were require, check, and ensure Require being the only one mentioned in this pitch

If a required method was never implemented, shouldn't it still crash in a release build? Each of these Fatal conditions refers to a specific situation that should never be reached. Or am I misunderstanding you?

I'd like to hear more about the ways Swift could adopt design-by-contract features though.

I think thatā€™s exactly what Iā€™m trying to get at is that the semantics of what these should look like is up to the team deciding to adopt them. They might want them to crash in any build type and another team might want some to crash in Debug but not release.