I have a couple complaints, all of which are non-issues in TypeScript.
I worked on a dependency injection library. The crux of the library is a single method, resolve
:
public protocol Resolver {
func resolve<T>(_ type: T.Type, name: String?) -> T
}
In principle, the library could store a dictionary and return dict[(type, name)]!
. It doesn't do that for several reasons, but that's the core of the idea behind it.
First problem: no default values in protocols. The vast majority of the time, the name
is going to be nil
, so it doesn't need to be provided. In TypeScript, I can do this easily:
// TS doesn't have an equivalent to Swift's `T.Type`, so you do this instead.
interface Constructor<T extends object | null> {
constructor(...args: any[]): T;
}
interface Resolver {
// By typing `name?: string` rather than `name: string | undefined`, it defaults to `undefined`.
resolve<T extends object | null>(type: Constructor<T>, name?: string): T;
}
Now, onto the issue of how to store it. It can't be stored in a dictionary or dictionary-like container, since there's no way to represent the behavior at the type level -- at least, not more safely than [(Any.Type, String?): Any]
. Instead, it stores an array of registration objects, which I want to model as:
public protocol Registration<T> {
associatedtype T
var name: String? { get }
func resolve(_ resolver: Resolver) -> T
}
extension Registration {
public var type: T.Type { T.self }
}
However, this runs into a problem when implementing the resolve method. It seems deceptively straightforward:
private var registrations: [any Registration]
public func resolve<T>(_ type: T.Type, name: String?) -> T {
for registration in self.registrations {
// You can't do `as? Registration<T>` because the associatedtype doesn't work like that...
if regstration.type == type && registration.name == name {
// ...but because I haven't cast it to a concrete type, this next line is illegal:
return registration.resolve(self)
}
}
preconditionFailure("No matching registration found")
}
This isn't a problem in TypeScript because you can write your own typecheck function:
interface Registration<out T extends object | null> {
readonly type: Constructor<T>;
readonly name: string | undefined;
resolve(resolver: Resolver): T;
}
class Factory implements Resolver {
private readonly registrations: Registration<object | null>[] = [];
private checkRegistration<T extends object | null>(
registration: Registration<object | null>,
type: Constructor<T>
): registration is Registration<T> {
return registration.type === type;
}
resolve<T extends object | null>(type: Constructor<T>, name?: string): T {
for (const registration of this.registrations) {
if (this.checkRegistration(registration, type) && registration.name === name) {
return registration.resolve(this);
}
}
throw new Error('No matching registration found');
}
}
This highlights the second problem I have: Swift errs so hard on the side of type-safety, its associated types don't even work right. I originally wrote this in Swift 5.5 or 5.6, so I was hoping 5.7 would fix it, but unfortunately it didn't. The result is code littered with Any
and as!
, that's only safe if you follow the documentation and don't implement your registration like this:
struct Bad: Registration {
var type: Any.Type { String.self }
var name: String? { nil }
func resolve(_ resolver: Resolver) -> Any {
return 5
}
}
I've made as much of the user-facing API generic as possible, even providing a generic wrapper around the Any
-based registration, but I still can't prevent this from compiling.
My final gripe is that you can't say this:
func f<T1: AnyObject, T2: T1>(_ arg: T2) -> T1 {
return arg
}
This is another source of Any.Type
in that project, and as much as I can do checking at runtime, I was very disappointed that the compiler offered no help here. Again, TypeScript has no problem with this,
function f<T1, T2 extends T1>(arg: T2): T1 {
return arg;
}
and even lets you forgo the requirement that T1
be a class -- you could call f<boolean, boolean>(true)
.