Strong typealiases (alternatively: “i have too many Array-oids”)

i find that when i’m first learning how to use an API, such as:

func doSomethingWith(pants:Pants)

the first question i ask is: “how do i obtain an instance of Pants”?

and usually, that is not hard to deduce, even if the API lacks documentation, because i can click through to the definition or doc page for Pants, and see it has initializers, static func constructors, etc. and one of those is probably the answer to “how do i obtain an instance of Pants?”

but i find it’s much harder when the API looks like:

func doSomethingWith(socks:[Sock])

because my first question then becomes the slightly different: “how do i obtain an array of Sock”?

and that’s way harder to deduce because the information you’re looking for probably isn’t going to be found by browsing Sock’s initializers.

doSomethingWith(socks: [.leftSock(color), .leftSock(color)]) // nope!

instead, the API designer probably intended for you to obtain the array from some kind of factory method.

doSomethingWith(socks: drawer.getPairOfSocks(color)) // yes!

so, as an API designer, it might be more helpful if i made an Array-oid wrapper type like:

struct Socks
{
    let elements:[Sock]

    private
    init(elements:[Sock])
    {
        self.elements = elements
    }
}
extension Socks
{
    public
    init(from drawer:inout Drawer, color:Color)
    {
        ...
    }
}

so that the call site looks like:

doSomethingWith(socks: .init(from: &drawer, color: color))

which i feel is way better from a pedagogical standpoint, because you can look at the signature of doSomethingWith(socks:), click through to Socks, notice that it has an init(from:color:) API, and voilà, you have learned how to use the socks API.

but the thing is, Socks’ pedagogical/syntactical role is its only role. the type is otherwise completely unjustified. and having lots of public types comes with costs that are still hard to optimize away.

so (at the risk of sounding radical), perhaps we need a new nominal type that is like a typealias but is able to “own” nested declarations in ways that typealias symbols cannot?

4 Likes

How is this different from a struct or enum though?

a “strong typealias” would only be an API concept, its members would still be callable from the aliased type, the only difference is if i define a nested declaration in an extension block on that typealias, then the nested declaration is parented to the typealias instead of the aliased type (as it currently is, since SymbolGraphGen always eagerly resolves typealiases when computing memberOf relationships.)

@strong typealias Socks = [Sock]

extension Socks
{
    /// This is a member of ``Socks``
    /// (but it’s still callable in an Array context)
    public
    init(from drawer:inout Drawer, color:Color)
    {
        ...
    }
}

extension Array<Sock>
{
    /// This is still a member of ``Swift.Array``
    public
    init(other:Never)
}
1 Like

I hit this quite often. My solution has always been to go ahead and declare the new types, which sometimes go beyond what you call a syntactical role (i.e. they also know how to manipulate the wrapped sequence/collection, conform to protocols etc).

But this would be the only use case for a "strong typealias" ever (or?). I which case, does it worth the trouble adding a new keyword/attribute/etc?

i would argue that (some of) these are in fact “syntactical roles”, because otherwise those methods may as well be extensions on Array itself, were it not for the fact that they would be lexically attributed to Array, which is not what we want.

(conforming to protocols is obviously different, that should probably still require a full-fledged struct wrapper.)

basically, what i want is for the API to be part of Array, but for it to be curated (in documentation, in code completion, etc.) under the typealias.

drawer.getPairOfSocks might return a plain array of socks. And either doSomethingWith or Sock or both might have a comment that explains how to make an array of socks if doing it by normal means like [.leftSock(color), .leftSock(color)] is unwise for some reason.

one of the things i enjoy about swift is we have established patterns for doing things that make it easier to start working with code you did not write (or code you wrote a “long” time ago).

one of those patterns is: if you need an X, you can get an X by calling one of its initializers. sure, you could load a pair of Socks from an unsafe raw buffer pointer, but that’s probably not how the type is intended to be used.

in practice, this means you can type let socks:Socks = . , and let code completion show you its initializers and static func constructors, which i’ve found to be a phenomenally productive workflow.

relying on notes written in doc comments spread across nearby symbols feels like a step backwards to me.

1 Like

Also consider going in the opposite direction:

drawer
    .getPairOfSocks(color)
    .doSomething()
    ...

where

extension [Sock] {
    func doSomething() { ... }
}

Spitballing, but here’s a way to build something pretty similar out of two orthogonal features:

  1. Raw-valued structs, just like raw-valued enums (but without the requirement that the raw type be expressible by a literal):
struct Socks: [Sock] {}

// Expands into:
struct Socks: RawRepresentable {
    init(rawValue: [Sock]) { self.rawValue = rawValue }
    var rawValue: [Sock]
}
  1. RawRepresentable types get a derived conformance for any protocol declared on the wrapper type that’s also conformed to by the raw type (as long as there’s no explicit implementation):
struct Socks: [Sock], RandomAccessCollection, MutableCollection, RangeReplaceableCollection {
   // or maybe it’s `@rawValue RandomAccessCollection` etc.
}
5 Likes

Wasn't SE-0299 intended as your solution?

It seems to me focusing on, or using Array at all, might not be a good choice…

extension Sequence<Sock> {
  static func from(_ drawer: inout Drawer, color: Color) -> some Sequence<Sock> {
    drawer.getPairOfSocks(color)
  }
}

func doSomethingWith(socks: some Sequence<Sock>) { }

…but if necessary, what about

extension Sequence where Self == [Sock] {

?

The protocol there is arbitrary; it could be anything in the hierarchy. Nobody needs the following spelling for anything else, so maybe it should be appropriated for your use case.

extension Array where Self == [Sock]

This could also be useful for CChar if you could make it ExpressibleByUnicodeScalarLiteral.

IMO Socks is not a so convincing example, since extension Array where Element == Sock can also help (assuming you’re holding the Sock type so this is not retroactive).

In more cases we may want a thin wrapper on standard library types, eg. String or Array<UInt8>, and different libraries have their own desired extensions. That’s where a “strong” type alias (or in my word, a type “fork”) shines.

this is a really interesting idea, at least for the immutable interfaces like RandomAccessCollection. but i’m not sure how it would work for MutableCollection, RangeReplaceableCollection, etc because Self.rawValue is get-only.

if you read the example closely, this is actually almost what i'm proposing. the difference is a bare extension on Array is undesirable because, well, Array is a terrible parent. Array has a lot of API, it is a shared namespace, et cetera. i would much rather parent application-specific members to a custom typealias.

I believe this is more of a tooling problem, i.e. the autocompletion should prefer the more specific extension. It’s almost impossible (and weird/not Swifty) to ban APIs from Array with any typealias of it — at least you cannot ignore the protocol conformance, and by using a protocol you’re actually using the underlying API. So if you’re complaining that It’s hard for the user to find the specific API when coding, this doesn’t require amendments to the Swift language itself.

For any other reason creating a new type (maybe with macros) is almost unavoidable. I have no clue why a typealias is preferable for a totally fresh type as it’s also part of the API (and ABI) interface to maintain, so there’s no difference with yet another type from the aspect of library maintainers.

Ah, you’re right. We’d probably have to reject those unless rawValue happened to be mutable.

1 Like

i disagree that this is a tooling problem. to be clear, the problem is not that application-specific API is showing up on the doc page for Array. Array has hundreds of nested declarations, a few more won't hurt.

the problem is that interesting API is getting lost in a slush pile of Array members, because there is no such thing as a symbol for Array<Sock> or Array where Element:SockProtocol, those types are merely specializations of the generic type Array.

(this, i think, is a crucial difference between autocomplete and documentation - autocomplete can benefit from knowledge of generic context, documentation by its very nature cannot.)

maybe we could "promote" third-party extensions to Array to the top of the list, since one could argue that those are "more relevant" than standard library members. but you need to answer the question: what happens if more than one visible module extends Array with extra members? whose extensions take precedence? if everyone's extensions are mushed together, have we really accomplished anything?

To me that still feels like a tooling problem; that some existing tools can’t surface information most relevant to Array<Sock>, optionally broken down by module, isn’t an inescapable consequence of anything the compiler is doing.

A sufficiently advanced documentation tool could conceivably allow users to view APIs specifically filtered by arbitrary generic constraints just like autocomplete can do—and by the sound of it, that would answer a significant chunk of the motivating problem here, so perhaps that level of sophistication is already necessary.

1 Like

we cannot surface information specific to Array<Sock> because Array<Sock> isn't a real symbol, Array is.

indeed, the motivation for adding a typealias is to give something like Array<Sock> a name that we can use to identify what would otherwise be just another specialization of Array.

we can give a typealias like Socks = [Sock] a doc page and a URL, and we can reference it with symbol links. we cannot possibly do any of those things with [Sock] itself.

Why not? The semantics of the language itself do nothing to limit documentation to “real symbols,” so the things we “cannot possibly do” here must be tooling limitations, no?

1 Like

the short answer is that symbol links don’t encode type constraints. we can link to things like dictionary/keys, we can’t link to things like dictionary<string/index, unicode/scalar>/keys, at least under our current symbolization model.

and while symbol links and URLs are different things, they both rely on a particular notion of symbol identity that does not distinguish between different generic constraints on the same generic type. and i'm not sure if we want to start encoding angle brackets, commas, spaces, etc into URLs.