2

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:

  • Is there any way to make a non-generic Cloneable interface or type that captures the idea that the object's clone should be the same type as itself? If not, what properties of the typescript type system makes this so?
  • Out of the three generic 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?
  • Is there a name for the pattern interface S<T extends S<T>> in typescript, or more generally in any type system?
Edward Hou
  • 23
  • 4

1 Answers1

2

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])];

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • I missed the polymorphic `this` when reading through the docs! That was exactly what I was looking for. You mentioned casting the value of the type to `this` in the `.clone()` method creates issues when that type gets extended. It seems to create the requirement that subtype extending that type has to override the `.clone()` method to work with the subtype, is that correct? Thanks for the detailed answer. – Edward Hou Mar 06 '21 at 00:13