KISS

Keep It Simple Stupid

swift: Functions are more generic `enum`s

| comments

Let’s say you have a structure with two very similar properties and you need to choose one of the two. You don’t know which one at compile time, so you need to have a parameter to change that at runtime. This post shows this in a very simple and contrived example, given the data structures:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Person {
    let firstName: String
    let lastName: String
    let age: UInt8
}

struct PersonId {
    let name: String
    let age: UInt8
}

let johnDoe = Person(firstName: "John", lastName: "Doe", age: 42)
print("Input: \(johnDoe)")

// Input: Person(firstName: "John", lastName: "Doe", age: 42)

The Person (the input) has two names: firstName and lastName, and when you need to convert it to a PersonId (the output) you need to select one of the names. How do you do that?

A Bool parameter

The simplest option to implement a switch between two options is to use a Bool parameter. Even in languages where the function parameters can be named, as in swift, it is not very readable to use true/false for two cases — which value maps to which case? You have to read the parameter name to find out:

1
2
3
4
5
6
7
8
9
10
11
12
13
extension PersonId {
    // Version 0, using a `Bool` toggle
    static func from(person: Person, useFirstName: Bool) -> PersonId {
        let name = useFirstName ? person.firstName : person.lastName
        return PersonId(name: name, age: person.age)
    }
}

let johnDoe_v0_firstName = PersonId.from(person: johnDoe, useFirstName: true)
let johnDoe_v0_lastName = PersonId.from(person: johnDoe, useFirstName: false)
print("v0: \(johnDoe_v0_firstName), \(johnDoe_v0_lastName)")

// v0: PersonId(name: "John", age: 42), PersonId(name: "Doe", age: 42)

It works, but not entirely clear. For example, how do you pick which case is represented by true? And when you’re reading useFirstName: false, it’s clear that it’s not using the first name and you need to apply a tiny bit of your mental energy to recollect what the other case is — the last name here.

An enum parameter

It’s better to use a custom enum (a sum type) with the cases exactly describing what to select:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension PersonId {
    enum PersonNameSwitch {
        case firstName
        case lastName
    }

    // Version 1, using a custom-crafted `enum`
    static func from(person: Person, using nameSwitch: PersonNameSwitch) -> PersonId {
        let name: String = {
            switch nameSwitch {
            case .firstName: return person.firstName
            case .lastName: return person.lastName
            }
        }()

        return PersonId(name: name, age: person.age)
    }
}

let johnDoe_v1_firstName = PersonId.from(person: johnDoe, using: .firstName)
let johnDoe_v1_lastName = PersonId.from(person: johnDoe, using: .lastName)
print("v1: \(johnDoe_v1_firstName), \(johnDoe_v1_lastName)")

// v1: PersonId(name: "John", age: 42), PersonId(name: "Doe", age: 42)

The using: .lastName syntax in the example is much cleaner and easier to understand. This enum is isomorphic to the Bool from the previous section, which means that you can convert between those two types back and forth without losing any information. It’s trivial to write the converters:

1
2
3
4
5
6
7
8
9
extension PersonNameSwitch {
    func isFirstName() -> Bool {
        return self == .firstName
    }

    static func from(isFirstName: Bool) -> PersonNameSwitch {
        return isFirstName ? .firstName : .lastName
    }
}

So in effect, true = firstName and false = lastName where the = sign means “also known as” (this is a terribly unmathematical definition).

Also note that I’m using an inline lambda where I pick the right field. This is a workaround for the fact that the switch is a statement in swift, not an expression. Briefly, an expression returns a value, whereas a statement does not. If it were an expression, the code would be nicer:

1
2
3
4
let name = switch nameSwitch {
    case .firstName: person.firstName
    case .lastName: person.lastName
}

A function parameter

In the previous example, that mapping in the switch looks very simple. We can actually get rid of the specific enum and replace it with a function!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension PersonId {
    typealias PersonNameSelector = (Person) -> String

    // Version 2, using a selector function
    static func from(person: Person, using nameSelector: PersonNameSelector) -> PersonId {
        return PersonId(name: nameSelector(person), age: person.age)
    }
}

let johnDoe_v2_firstName = PersonId.from(person: johnDoe, using: { $0.firstName })
let johnDoe_v2_lastName = PersonId.from(person: johnDoe, using: { $0.lastName })
print("v2: \(johnDoe_v2_firstName), \(johnDoe_v2_lastName)")

// v2: PersonId(name: "John", age: 42), PersonId(name: "Doe", age: 42)

Here the function of the necessary type Person → String is given a nice, more abstract name PersonNameSelector.

If you’re using these typical selectors often, you can extract them very easily as values:

1
2
3
4
5
let lastNameSelector: PersonId.PersonNameSelector = { $0.lastName }
let johnDoe_v2_lastName_ = PersonId.from(person: johnDoe, using: lastNameSelector)
print(johnDoe_v2_lastName_)

// PersonId(name: "Doe", age: 42)

As an advantage, it’s also very easy for the caller to extend the implementation by providing new selectors!

1
2
3
4
let johnDoe_v2_bothNames = PersonId.from(person: johnDoe, using: { $0.firstName + "_" + $0.lastName })
print(johnDoe_v2_bothNames)

// PersonId(name: "John_Doe", age: 42)

I believe this is what the Open/Closed Principle is about: the implementation of the PersonId.from(person:using:) function is closed for modification, and yet it is open for extension by providing an alternative PersonNameSelector parameter.

A few suggested enhancements follow.

Using properties as functions

We can simplify the calling syntax by using properties as functions, even though we have to declare them manually because swift doesn’t generate those for us:

1
2
3
4
5
6
7
8
9
10
extension Person {
    static func firstName(_ self_: Person) -> String { return self_.firstName }
    static func lastName(_ self_: Person) -> String { return self_.lastName }
}

let johnDoe_v2_prop_firstName = PersonId.from(person: johnDoe, using: Person.firstName)
let johnDoe_v2_prop_lastName = PersonId.from(person: johnDoe, using: Person.lastName)
print("\(johnDoe_v2_prop_firstName), \(johnDoe_v2_prop_lastName)")

// PersonId(name: "John", age: 42), PersonId(name: "Doe", age: 42)

Curried factory function

Also, if we curry our factory function PersonId.from(person:using:), we can partially apply the first argument and get a function that only takes a Person:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension PersonId {
    // Version 3, which is manually curried version 2
    static func fromPerson(using nameSelector: @escaping PersonNameSelector) -> (Person) -> PersonId {
        return { person in
            return PersonId(name: nameSelector(person), age: person.age)
        }
    }
}

let fromPersonUsingFirstName = PersonId.fromPerson(using: Person.firstName)
let johnDoe_v2_curried_firstName = fromPersonUsingFirstName(johnDoe)
print(johnDoe_v2_curried_firstName)

// PersonId(name: "John", age: 42)

This allows us to reuse the fromPersonUsingFirstName function in multiple places which only know about Persons and shouldn’t care about which specific PersonNameSelector to use.

Conclusion

Using a Bool parameter is a very generic way because its two values true and false are very generic and don’t match our domain model. You have to work around that by naming the function parameter of type Bool. A better idea is to introduce an enum designed for the domain model, whose values are more readable, but are still limiting. A function is a more abstract way of selecting a property than an enum, which also allows easier extension.

Comments