Designing custom immutable types

I’m designing a custom JSON enum type, something like this:

enum JSON: Equatable {
    case string(String)
    case number(Float)
    case object([String:JSON])
    case array([JSON])
    case bool(Bool)
    case null
}

Initially it was read-only, but now users of the type are requesting writable subscripts and other mutation features. I’m not sure if I should offer only immutable API such as updating(key:to:) -> JSON or removing(key:) -> JSON, or if I should allow in-place mutation. In other languages I would go for the immutable API, but Swift already offers nice opt-in immutability with let vs. var. So should I simply offer a mutable API and let/var users decide? How do people approach this?

You could offer both, similar to how we have shuffle vs shuffled.

Doesn’t it feel strange to have a part of the API where all the functions just create a copy of self, apply a mutating method and return it?

1 Like

The only thing I find strange about it is that I can't always remember which one is the mutating version!

Both uses (mutating and copying) are valid from the client’s perspective.

If you only provide the mutating operation yourself, copies require boilerplate:

let copy = original
copy.mutate()
doSomething(with: copy)

If you only provide the copying operation yourself, mutating requires less boilerplate but superfluous memory allocation:

original = original.mutated()

The best thing to do is implement a mutating method and provide a copying method that forwards to it:

mutating func mutate() {
    // ...
}
func mutated() -> Self {
    var copy = self
    copy.mutate()
    return copy
}

The forwarding methods can be somewhat abbreviated using a pattern like this, which also prevents the following common, easy‐to‐miss typo:

func mutated() -> Self {
    var copy = self
    copy.mutate()
    return self
}

The API Design Guidelines recommend using:

  • infinitives for mutating operations: mutate(), and
  • participles for copying operations: mutated() (or mutating(argument) if it requires a direct object—appending(x) is less awkward than appended(x)).
    • Express it as a computed property if it is O(1) and has no arguments; express it as a function if it is more complex. (e.g. Collection’s first vs sorted())
5 Likes

Thank you, in the end I decided to offer both options. (Would it be interesting to have the option to get the non-mutating versions for free? Where something like @someAnnotation mutating shuffle() would also generate a shuffling counterpart that would copy self and apply the mutating method.)

And if you have something like the much-talked with you can do the copying version from the mutating one without writing the boilerplate (or at least the boilerplate becomes calling the function)

2 Likes

That’s what I did. I think it’s a good argument for having the function in the standard library.

Thank you! I really do know which is which, but I always have to think for 1.5 seconds before selecting the one I want in the typeahead popup.