Skapa ett generiskt "Cloneable"-gränssnitt i TypeScript
Tänk på ett TypeScript-gränssnitt som beskriver begränsningen att ett objekt har en .clone()-metod som returnerar ett objekt av samma typ (eller potentiellt ett objekt som kan tilldelas dess egen typ). Ett exempel på vad jag föreställer mig:
// 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])];
Även om detta fungerar, misslyckas gränssnittet med att fånga vår förståelse av hur en Cloneable bör bete sig: det tillåter en klass Faker vars .clone()-metod returnerar ett klonbart objekt av någon annan klass som inte kan tilldelas typen Faker. För att lösa det problemet kan vi parameterisera Cloneable-gränssnittet med typ:
// 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;
}
och sedan för att begränsa en typparameter till klonbar kan vi ange T extends Cloneable<T>:
// Interface that requires a cloneable
interface RequiresCloneable<T extends Cloneable<T>> {
//...
}
Alla tre parametriserade gränssnitt accepterar klassen A. Vi förlorar dock möjligheten att specificera att ett visst värde ska vara klonbart utan att känna till dess implementationstyp:
const clonableA: Cloneable<A> = new A([1]);
const clonableOfUnknownType: Cloneable<unknown>; // admits Fakers with approach 1 and 2, doesn't compile with approach 3
Jag försöker förstå hur typesystemet fungerar och vad som är möjligt och omöjligt under typesystemet. Mina frågor är:
- Finns det något sätt att skapa ett icke-generiskt Cloneable-gränssnitt eller typ som fångar idén att objektets klon ska vara av samma typ som sig självt? Om inte, vilka egenskaper hos TypeScript-typsystemet gör detta så?
- Av de tre generiska
Cloneable<T>-gränssnitten jag beskrev ovan, vilket är det bästa? Bästa kan potentiellt betyda tydligast, enklast, mest koncist, mest idiomatiskt eller striktast. Det verkar som att det tredje är det enda gränssnittet som inte tillåter en falsk kopia. Finns det något bättre sätt att göra det de gör? - Finns det ett namn på mönstret
interface S<T extends S<T>>i TypeScript, eller mer generellt i något typsystem?
Svar #1
TypeScript har polymorfa this-typer, där du använder typen som heter this för att hänvisa till "samma typ som sig själv" som du kallar det.
interface Cloneable {
clone(): this;
}
I värden av subtyper av Cloneable kommer typen this att vara densamma som den subtypen:
interface Foo extends Cloneable {
bar: string;
}
declare const foo: Foo;
const otherFoo = foo.clone();
otherFoo.bar.toUpperCase();
Observera dock att när du faktiskt försöker implementera Cloneable, kommer kompileraren att motsätta sig allt som försöker returnera något annat än samma this-objekt som du redan hade. I din klass A händer detta:
clone() { // error! Type 'A' is not assignable to type 'this'.
return new A(this.a.slice()); // returns a deep copy of itself
}
Det är faktiskt ett bra fel, eftersom du inte får välja var underordningen slutar. Någon kan komma och utvidga A (eller tillhandahålla ett värde av typen A & NågotAnnat), och this kommer att smalna av samtidigt:
class B extends A {
z: string = "oopsie";
}
declare const b: B;
b.clone().z.toUpperCase();
Om din clone()-metod i A inte förutser möjliga subtyper som B, kommer den inte att fungera på ett typ-säkert sätt. Hur som helst, att prata om hur man hanterar detta generellt är förmodligen en sidospår, men om du inte riktigt bryr dig om möjliga subtyper och bara vill att kompilatorn ska acceptera din implementation, kommer du att behöva en typförändring, och viss försiktighet vid hantering av subtyper:
clone() {
return new A(this.a.slice()) as this;
}
Den andra tekniken du pratar om:
interface Cloneable<T extends Cloneable<T>> {
clone(): T;
}
kallas F-Bounded Polymorphism, där "F" i detta fall bara betyder "Funktion". Du begränsar (i TS kallar vi detta att begränsa) typen T med en annan typ som är en funktion av den, F<T>. Det är också känt som "rekursiv begränsning".
Observera att polymorf this är en implicit sorts F-bunden polymorfism, där this kan ses som en sorts "skugga" generisk typparameter. Du har mindre kontroll över this än du har över den explicita typparametern T, vilket gör att du kan välja var subtypningen når sin botten:
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
Här producerar b.clone() ett värde av typen A och inte B, eftersom A implementerar Clonable<A> och inte Clonable<this>.
Men nackdelen här är att du måste bära med dig denna "extra" typparameter överallt där du vill referera till Cloneable:
const arrayOfCloneables: Cloneable<A>[] = [new A([1, 2, 3])];
Lekplatslänk till kod
