Retrieve type of initializer expression in accessor macro

Is there a way in Swift to retrieve the type of an initializer expression in order to define another variable of the same type?

Usage in accessor macros

Accessor macros like the DictionaryStorage example could benefit from that and would allow users of the macro to omit type annotations if given an initializer expression.

Example from the evolution proposal

Swift evolution proposal SE-0389: attached macros introduces attached accessor macros using the example of a DictionaryStorage macro, e.g.

struct MyStruct {
  var storage: [AnyHashable: Any] = [:]
  
  @DictionaryStorage
  var name: String
  
  @DictionaryStorage(key: "birth_date")
  var birthDate: Date?
}

get's expanded into:

struct MyStruct {
  var storage: [String: Any] = [:]
  
  var name: String {
    get { 
      storage["name"]! as! String
    }
    
    set {
      storage["name"] = newValue
    }
  }
  
  var birthDate: Date? {
    get {
      storage["birth_date"] as Date?
    }
    
    set {
      if let newValue {
        storage["birth_date"] = newValue
      } else {
        storage.removeValue(forKey: "birth_date")
      }
    }
  }
}

Initial values

In Swift declarations code authors are allowed to omit type annotations if the type can be inferred from an initializer expression, e.g. write

  let greeting = "Hallo, world!"

instead of the more complete

  let greeting: String = "Hallo, world!"

If in the above example of the DictionaryStorage macro the macro user had written:

  @DictionaryStorage var name = "Rezart"

the macro would be unable to expand, because it would not know to which type to cast the return value of the dictionary, e.g.

  var name: String {
    get { 
      storage["name"]! as! <WHAT SHALL GO HERE?>
    }
    ...
  }

Our current solution

At Astzweig we developed swift-syntax-literals specifically for this problem, in order to allow users of our macros to omit type annotation for at least some well known literals. Because of this limitation and because this approach doesn't have the power of Swift's type inference, we are looking for a better way.

func castInferred<T, R>(_ value: T) -> R? {
  value as? R
}

var storage: [AnyHashable: Any] = [:]
var name: String {
  get {
    castInferred(storage["name"]!)!
  }
  set {
    storage["name"] = newValue
  }
}

@dmt How would Swift know from the example above, that castInferred has to yield a String, if the user did not annotate name as type String?

I might be wrong here, but there is probably an exception for computed properties syntesized via macro expansion. After the transformation is applied you'll end up with

var name = "foo" {
  get {
    castInferred(storage["name"]!)!
  }
  set {
    storage["name"] = newValue
  }
}

which is not normally allowed, but if you take a look at an expansion of the ObservationTracked macro (from Observable macro) you'll see something like

final class MyCounter {
  var count = 0
  {
    init(initialValue) initializes (_count) {
      _count = initialValue
    }

    get {
      access(keyPath: \.count)
      return _count
    }

    set {
      withMutation(keyPath: \.count) {
        _count = newValue
      }
    }
  }
}

There will be another issue you'll have to deal with: setters won't be called for initialization, so you will have to implement init accessor.

  var storage: [AnyHashable: Any] = [:]

  var name = "foobar" {
    @storageRestrictions(accesses: storage)
    init(initialValue) {
      storage["name"] = initialValue
    }
    get {
      castInferred(storage["name"]!)!
    }
    set {
      storage["name"] = newValue
    }
  }

NOTE: as you mutate a COW underlying storage from your syntesized properties, I suggest you to use _read and _modify accessors instead of get and set.

EDIT: At the time of writting Xcode 15.0b5 is bundled with a version of swift that uses the previous syntax for init accessors' storage restrictions. Correction:

    init(initialValue) accesses(storage) {
      storage["name"] = initialValue
    }

But I believe the release version of 15.0 will get the final version of SE-0400

It might be better to prevent the pollution of the common namespace with the castInferred function by defining the inferring function within the get accessor:

    get {
      func get<R>() -> R {
        storage["name"]! as! R
      }
      return get()
    }
1 Like

I tried the solution you suggested and Swift is not able to infer the type of ResultType in the following macro expansion:

struct MyStruct {
  ...
  var name = "Rezart" {
    get {
        func getStorageValue<ReturnType>() -> ReturnType {
            let defaultValue = "Rezart"
            let storedValue = storage["name"]
            let value = storedValue as? ReturnType
            return value ?? defaultValue
        }
        getStorageValue()
    }
  }
}

Do you have any idea what could be causing the problem here?

  1. let defaultValue = "Rezart" How is this generated? In general this can't be legal because from the compiler perspective ReturnType within getStorageValue function isn't guaranteed to be equal to the result type of enclosing getter.
    I suggest to pass the default via a parameter func getStorageValue<ReturnType>(fallback: ReturnType) -> ReturnType or do nil coaliasing outside of the function (at the callsite).
  2. return is missing: return getStorageValue() ?
1 Like

Awesome! This works:

struct MyStruct {
  ...
  var name = "Rezart" {
    get {
        let defaultValue = "Rezart"
        func getStorageValue<ReturnType>(usingFallback defaultValue: ReturnType) -> ReturnType {
            let storedValue = storage["name"]
            let value = storedValue as? ReturnType
            return value ?? defaultValue
        }
        return getStorageValue(usingFallback: defaultValue)
    }
  }
}

Why is it though, that moving defaultValue outside of getStorageValue allows type inference to work? Just annotating defaultValue inside of getStorageValue with ReturnType is no sufficient, e.g. let defaultValue: ReturnType = "Rezart".

Nonono)
The inference of the return type of the call to getStorageValue to the return type of the getter is possible because of return statement.
let defaultValue: ReturnType = "Rezart" is another issue.
The compile can't guarantee that the ReturnType and the type of the variable initializing expression (String is this case) are the same type. It should produce a error something like "Can't assign value of type 'String' to 'ReturnType'"

1 Like

Thank you very much @dmt!

If I understand correctly, getStorageValue is not compilable in the case, where defaultValue is defined inside getStorageValue because the compiler cannot guarantee that the generic type ReturnValue will conform to ExpressibleByStringLiteral protocol for every possible specialization.

While on the other hand, if we move defaultValue outside, getStorageValue does compile and outside of getStorageValue the compiler knows ReturnType to be of type String, because of the return statement. Thus it's alright to pass in defaultValue as an argument.

Correct?

Yep, that's right