Consider a typescript interface that describes the constraint that an object has a .clone()
method that returns an object of the same type (or potentially an object assignable to its own type). An example of what I'm imagining:
// Naive definition
interface Cloneable {
clone: () => Clonable;
}
class A implements Cloneable {
a: number[];
constructor(a: number[]) {
this.a = a;
}
clone(): A {
return new A(this.a.slice()); // returns a deep copy of itself
}
}
interface RequiresCloneable<T extends Cloneable> {
//...
}
const arrayOfCloneables: Clonable[] = [new A([1,2,3])];
While this works, the interface fails to capture our understanding of how a Cloneable should behave: it admits a class Faker
whose .clone()
method returns a cloneable object of some other class that is unassignable to type Faker
. To solve that problem we can parameterize the Cloneable interface by type:
// Approach 1
interface Cloneable<T> {
clone: () => T;
}
// Approach 2
interface Cloneable<T> {
clone: () => Cloneable<T>;
}
// Approach 3. This pattern looks quite strange but this seems to work the best.
interface Cloneable<T extends Cloneable<T>> {
clone: () => T;
}
and then to constrain a type parameter to cloneable we can specify T extends Cloneable<T>
:
// Interface that requires a cloneable
interface RequiresCloneable<T extends Cloneable<T>> {
//...
}
All of the three parameterized interfaces admit class A
. However we lose the ability to specify that a particular value should be Cloneable without knowing its implementation type:
const clonableA: Cloneable<A> = new A([1]);
const clonableOfUnknownType: Cloneable<unknown>; // admits Fakers with approach 1 and 2, doesn't compile with approach 3
I'm trying to wrap my head around how the type system works and what is possible and impossible under the type system. My questions are:
Cloneable<T>
interfaces I described above, which one is the best? Best potentially meaning clearest, simplest, most concise, most idiomatic, or strictest. It seems that the third is the only interface that doesn't admit a faker. Is there any better way to do what they are doing?interface S<T extends S<T>>
in typescript, or more generally in any type system? TypeScript has polymorphic this
types , where you use the type named this
to refer to the "same type as itself" as you call it.
interface Cloneable {
clone(): this;
}
In values of subtypes of Cloneable
, the type this
will be the same as that subtype:
interface Foo extends Cloneable {
bar: string;
}
declare const foo: Foo;
const otherFoo = foo.clone();
otherFoo.bar.toUpperCase();
Note though that when you try to actually implement Cloneable
, the compiler will balk at anything which tries to return something other than the same this
object you already had. In your class A
, this happens:
clone() { // error! Type 'A' is not assignable to type 'this'.
return new A(this.a.slice()); // returns a deep copy of itself
}
That's actually a good error, because you don't get to choose where the subtyping bottoms out. Someone can come along and extend A
(or provide a value of type A & SomethingElse
), and this
will narrow right along with it:
class B extends A {
z: string = "oopsie";
}
declare const b: B;
b.clone().z.toUpperCase();
If your clone()
method in A
doesn't anticipate possible subtypes like B
, then it won't work in a type safe way. Anyway, talking about how to handle this in general is probably a digression, but if you don't really care about possible subtypes and just want the compiler to accept your implementation, you'll need a type assertion , and some caution in the face of subtypes:
clone() {
return new A(this.a.slice()) as this;
}
The other technique you're talking about:
interface Cloneable<T extends Cloneable<T>> {
clone(): T;
}
is called F-Bounded Polymorphism , where the "F" in this case is just means "Function". You are bounding (in TS we call this constraining ) the type T
by another type which is a function of it, F<T>
. It's also known as "recursive bounding" .
Note that polymorphic this
is an implicit sort of F-bounded polymorphism, where this
can be seen as sort of a "shadow" generic type parameter. You have less control over this
than you do with the explicit type parameter T
, which enables you to choose where subtyping bottoms out:
class A implements Cloneable<A> {
/* ... */
clone() {
return new A(this.a.slice());
}
}
class B extends A {
z: string = "oopsie";
}
declare const b: B;
b.clone().z.toUpperCase() // error, no z
Here, b.clone()
produces a value of type A
and not B
, because A implements Clonable<A>
and not Clonable<this>
.
But the downside here is that you need to carry around this "extra" type parameter everywhere you want to refer to Cloneable
:
const arrayOfCloneables: Cloneable<A>[] = [new A([1, 2, 3])];
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.