Pitch: Strengthen Requirements for Synthesizing Decodable

The Problem

Right now it's very easy to accidentally introduce bugs into Decodable structures. For example, given the following type:

struct Settings: Decodable {
  let theme: String
  let updated: Date
}

We may modify it to make mocking or instantiating it easier by inlining the property value in the let.

struct Settings: Decodable {
  let theme: String = "light"
  let updated: Date
}

This assumption is wrong, though, and now theme will return "light" no matter what because initializers can't set over a lets initial value. In my mind, this structure is no longer decodable by default because it contains a property that cannot be decoded.

Solutions

I think whenever a let has a default, the compiler should stop and make the developer state their intentions more explicitly, e.g., the above may provide the following error:

:stop_sign: implementation of 'Decodable' cannot be automatically synthesized for types with assigned let properties

There are two potential solutions with fix-its:

1. Require explicit coding keys

:stop_sign: Do you want to add coding key stubs?

By stubbing out coding keys that omit fields with defaults, the intention becomes explicit:

struct Settings: Decodable {
  let theme: String = "light"
  let updated: Date

  enum CodingKeys: String, CodingKey {
    case updated
  }
}

2. Require an explicit initializer

:stop_sign: Do you want to add protocol stubs?

By providing an explicit init(from:) implementation, the intention becomes even more so:

struct Settings: Decodable {
  let theme: String = "light"
  let updated: Date

  init(from decoder: Decoder) {
    <#code#>
  }
}

3. Suggest loosening the let requirement

:stop_sign: Change 'let' to 'var' to make it mutable

This would make the decoding work again.

How does it play with Encodable?

One issue to consider is how this plays with types that are also Encodable. I don't foresee there to be problems here given the suggested solutions above, but I'm probably missing something.


I've introduced regressions into my code base again and again by accident with the current behavior. It all makes sense but it's easy to forget without the compiler assistance you get when defining an initializer and attempting to assign over a let property that already has a value.

I think introducing more requirements and diagnostics here will help keep some of us in better check :sweat_smile:

2 Likes

+1 from me that this deserves a discussion :+1:

I recently had to assist a student who got stuck because this did not generate a warning or error and he simply couldn't figure out what was wrong with his code.

An alternative direction would be to consider direct assignment to let properties to be a default initialization but still allow initializers to assign a different value if desired. The core team indicated support for exploring this direction in their deferral of SE-0018.

5 Likes

An alternative direction would be to consider direct assignment to let properties to be a default initialization but still allow initializers to assign a different value if desired. The core team indicated support for exploring this direction in their deferral of SE-0018 .

I'm a tiny bit conflicted. I like the logic of "an initializer can override" but if there's a chain of initialization, could it be overridden in more than one place, where will the checking be done and what about convenience initializers? It feels like a can of worms that maybe we don't want to touch?

I'm imagining new attributes like:

@ireallymeanletnow let constant = "nonchangeable"

Wouldn't it just be better to optimize the compiler so the constant isn't coded and is never used in initializers?

struct Person: Codable {
    let name = "Erica"
}

// Error: No codable properties found to autosynthesize `Codable` conformance for type `Person`

It would only be allowed to be initialized exactly once. The DI rules already track when properties are first initialized. Subsequent writes in initializers are only possible for var properties. It’s a change for sure, but I don’t think it’s a can of worms.

From a semantic point of view instance level constants are really only useful if the value can be defined during intiialization. The primary reason people use them in Swift is because they can be used in members of the type without using a fully qualified name while also not polluting the top-level namespace.

I think it would be reasonable to consider allowing implicit lookup of static constants (not any other static members) or in instance members of the same type. If we do that there would be no need for a new annotations, it would simply be spelled `static let’. This would better express the semantics of these constants than spelling them like they are instance properties (my understanding is that they are optimized out by the compiler and do not take up any space in instance storage).

2 Likes

My real world use is a stringity value used only at the instance level and not visible outside the class.

I don't like overlapping the meaning of static constants visible outside the class (Class.staticMember) and used from the implementation as if they were instance members (self.staticMember) unless you had a distinct keyword to differentiate the two.

This is where having Self would really help but I'd prefer:

public struct StructName {
    public static let vendedConstant = "Vended by type, available via `Self.vendedConstant`, but not via `self.vendedConstant`"
    private let internalConstant = "for use throughout this type" // or

    private let name = "Default Name" // This can be init'ed and provides a really nice language feature
    @static
    private let internalConstant = "Compiler optimized magic that doesn't contribute to Codable and can't be re-init'ed"
}
1 Like

Yeah, we should really think about that!

My suggestion is to use the |= to indicate a default value for decoding:

struct Settings: Decodable {
  let theme: String |= "light" // yes, this is the default for decoding when no value is given
  let updated: Date
}