typescript - Skriva en rekursiv mappad typ?


(inte säker på den titeln)

Jag skulle vilja acceptera godtyckligt nästlade objekt och arrayer, och dela primitiva värden i alla bladpositioner i två, målet är att hantera "smutsiga" och "pristina" värden av vilken typ som helst, samtidigt som en enhetlig navigerbarhet genom både den ursprungliga och den utökade strukturen bibehålls.

Ett enkelt exempel skulle vara som

[1]     -> [{dirty: 1, pristine: 1}]
{x:[1]} -> {x: [{dirty: 1, pristine: 1}]}

Typdefinitionen verkar vara den lättare delen. Jag har följande generiska villkorliga typ:

class DirtyPristinePrimitive<T> {
    dirty : T; pristine: T;
    constructor(v: T) {this.dirty = v; this.pristine = v;}
}

type DirtyPristine<T> = T extends string
    ? DirtyPristinePrimitive<string>
    : T extends Number
    ? DirtyPristinePrimitive<number>
    : T extends boolean
    ? DirtyPristinePrimitive<boolean>
    : T extends null
    ? DirtyPristinePrimitive<null>
    : T extends undefined
    ? DirtyPristinePrimitive<undefined>
    : T extends Array<infer U>
    ? Array<DirtyPristine<U>>
    : {[k in keyof T]: DirtyPristine<T[k]>};

Ovanstående verkar fungera om jag uttryckligen skickar in typer och inspekterar dem i IDE:n, åtminstone upp till några nivåer av inbäddning.

Problemet är att skriva en funktion för att skapa värden av denna typ från någon T. Jag har:

function dirtyPristine<T>(v: T) : DirtyPristine<T> {
    if (Array.isArray(v)) {
        return v.map(e => dirtyPristine(e));
    }
    else if (isObject(v)) {
        const result = {};
        for (const key of Object.keys(v)) {
            (<any>result)[key] = dirtyPristine((<any>v)[key]);
        }
        return result;
    }
    else {
        return new DirtyPristinePrimitive(v);
    }
}

Men vid alla 3 returuttalanden får kompilatorn ett felmeddelande med "Type ... is not assignable to DirtyPristine<T>".

Är detta möjligt?

Svar #1

Problemet är att TypeScript-kompilatorn i princip är oförmögen att utvärdera tilldelbarheten av villkorliga typer som beror på ospecificerade generiska typparametrar.

När du anropar dirtyPristine() på ett värde av en viss typ, kan kompilatorn härleda en specifik typ (som {x: string}) för T och därmed utvärdera DirtyPristine<T> (som {x: DirtyPristinePrimitive<T>}). Det är okej.

Men inuti implementationen av dirtyPristine() är typparametern T fortfarande ospecificerad. Kompilatorn ger i princip upp här. Den skjuter upp utvärderingen av DirtyPristine<T>, och därför vet den inte hur man avgör om ett värde faktiskt kan tilldelas DirtyPristine<T>... om inte värdet redan är känt för att vara av typen DirtyPristine<T>.

Detta är en smärtpunkt i TypeScript, och det är inte klart hur det borde hanteras. Det finns ett öppet förslag, microsoft/TypeScript#33912, att använda kontrollflöde i implementationen för att informera kompilatorn om att returvärden kan tilldelas till den villkorliga returtypen. Om du vill se att detta händer kan du gå till ärendet och ge det en 👍. Men det verkar inte finnas någon nödvändig rörelse där, och även om detta så småningom händer behöver du ändå fortsätta under tiden.

Lösningen här är att helt enkelt använda typförsäkringar om du är säker på att din kod producerar giltiga instanser av den avsedda villkorliga typen. Eller, eftersom det är irriterande att använda typförsäkringar på varje return-rad, kan du göra det moraliska ekvivalenten och ge dirtyPristine() en single-call-signaturöverlagring och ha implementeringssignaturen tillräckligt lös för att tysta varningarna (som (v: any) => any):

function dirtyPristine<T>(v: T): DirtyPristine<T>;
function dirtyPristine(v: any): any {
  if (Array.isArray(v)) {
    return v.map(e => dirtyPristine(e));
  }
  else if (isObject(v)) {
    const result: any = {};
    for (const key of Object.keys(v)) {
      result[key] = (dirtyPristine(v) as any)[key];
    }
    return result;
  }
  else {
    return new DirtyPristinePrimitive(v);
  }
}

Observera att påståenden som detta innebär att du har tagit bort ansvaret från kompilatorn för att verifiera typ- säkerhet. Den kommer inte att varna dig om du gör ett misstag, så var försiktig.


Lekplatslänk

Svar #2

Jag ser ingen bättre lösning än den från @jcalz som är väl förklarad.

Detta är ett förslag som åtgärdar ett problem ((dirtyPristine(v) as any)[key] borde vara dirtyPristine((v as any)[key]) för att matcha typdefinitionen av DirtyPristine) och inkluderar några förbättringar (enligt min åsikt men det är en fråga om smak) :

  • Klassen DirtyPristinePrimitive bör begränsas till att enbart innehålla primitiva värden enligt design → class DirtyPristinePrimitive<T extends primitive | nil> genom att använda de två typaliasen primitive och nil för att underlätta läsningen av koden
  • I DirtyPristinePrimitive-klassen föredrar jag att ha en (privat om nödvändigt) konstruktor som initierar dirty och pristine-egenskaperna och en fabrikmetod för att skapa en instans från ett enda värde.
  • Definitionen av typen DirtyPristine kan förenklas med hjälp av aliasen primitive och nil samt ett annat typealias DirtyPristineObject.
  • Blocket for (const key of Object.keys(v)) kan förkortas med reduce
type nil = null | undefined;
type primitive = boolean | number | string;

class DirtyPristinePrimitive<T extends primitive | nil> {
  static create<T extends primitive | nil>(value: T) {
    return new DirtyPristinePrimitive(value, value);
  }

  private constructor(
    public dirty: T,
    public pristine: T,
  ) {}
}

type DirtyPristineObject<T extends object> = {
  [k in keyof T]: DirtyPristine<T[k]>
};

type DirtyPristine<T> =
  T extends primitive | nil ? DirtyPristinePrimitive<T> :
  T extends Array<any> ? Array<DirtyPristine<any>> :
  T extends object ? DirtyPristineObject<T> :
  never;

function dirtyPristine<T>(v: T): DirtyPristine<T>;
function dirtyPristine(v: any): any {
  switch (typeof v) {
    case 'boolean':
    case 'number':
      return DirtyPristinePrimitive.create(v);

    case 'object':
      if (v == null) {
        return DirtyPristinePrimitive.create(v);
      }
      if (Array.isArray(v)) {
        const res = v.map(e => dirtyPristine(e) as DirtyPristine<any>);
        return res;
      }
      return Object.keys(v).reduce(
        (result, key) => ({ ...result, [key]: dirtyPristine((v as any)[key]) }),
        {} as any);

    default:
      throw new Error('Unsupported type');
  }
}