简体   繁体   中英

Creating a generic “Cloneable” interface in typescript

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?

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

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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM