generics - Kombinera två typer till ett gränssnitt elegant i Typescript


Mitt mål

Jag har en sträng enum E och ett interface I med identiska uppsättningar av nycklar. Jag vill konstruera en ny mappad typ. För varje delad nyckel k ska den använda enumvärdet E.k som ett egenskapsnamn. Typen på medlemmen I.k ska vara typen för denna nya egenskap.

Lite bakgrund/motivation för mitt användningsfall

Jag får objekt från ett REST-API. Jag kan inte ändra deras struktur. Objektens nyckelnamn är mycket svårlästa och fula på grund av äldre orsaker (jag simulerar detta i FooNames i exemplet). Detta gör utvecklingen smärtsam och ökar onödigt fel både i koden, men mer kritiskt i förståelsen, när man arbetar med dessa objekt och manipulerar dem.

Vi har dolt dessa namn med ett rent gränssnitt av vår egen (simulerat via "first" | "second" | "third"). Men när vi skriver tillbaka objekt till backend måste de ha den ":fule" strukturen igen. Det finns flera dussin objekttyper (med olika uppsättningar av fält vardera), vilket är det som gör det så smärtsamt att arbeta med förvirrande fältnamn.

Vi försöker minimera redundans - samtidigt som vi fortfarande har statisk typ- och strukturkontroll via TS-kompilatorn. Därför skulle en kartlagd typ som utlöser typkontroller baserat på de befintliga abstraktionerna vara mycket hjälpsam.

Kodexempel

Kan BackendObject-typen nedan på något sätt realiseras som en mappad typ i Typescript? Hittills har jag inte lyckats hitta ett sätt. Se denna playground för all kod i denna fråga.

// Two simple abstractions per object type, e.g. for a type Foo....
enum FooNames {
  first = 'FIRST_FIELD',
  second = 'TT_FIELD_SECOND',
  third = 'third_field_33'
}
interface FooTypes {
  first: string,
  second: number,
  third: boolean
}
// ... allow for generic well-formed objects with structure and typechecks:
interface FrontendObject<FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {[K in FieldNames]: FieldTypes[K]}
}

// Example object in the case of our imaginary type "Foo":
let checkedFooObject: FrontendObject<keyof typeof FooNames,FooTypes> = {
  fields: {  
    first: '',   // typechecks everywhere!
    second: 5,
    third: false,
//  extraProp: 'this is also checked and disallowed'
  }
}

// PROBLEM: The following structure is required to write objects back into database
interface FooBackendObject { 
  fields: {
    FIRST_FIELD: string,
    TT_FIELD_SECOND_TT: number,
    third_field_33: boolean
    // ...
    // Adding new fields manually is cumbersome and error-prone;
    // critical: no static structure or type checks available
  }
}
// IDEAL GOAL: Realize this as generic mapped type using the abstractions above like:
let FooObjectForBackend: BackendObject<FooNames,FooTypes> = {
  // build the ugly object, but supported by type and structure checks
};

Mina försök hittills

1. Enum (Namnen) + Interface (typer)

interface BackendObject1<FieldNames extends string, FieldTypes> {
  fields: {
    // FieldTypes cannot be indexed by F, which is now the ugly field name
    [F in FieldNames]: FieldTypes[F]; 
    // Syntax doesn't work; no reverse mapping in string-valued enum
    [F in FieldNames]: FieldTypes[FieldNames.F]; 
  }
}
// FAILURE Intended usage:
type FooObjectForBackend1 = BackendObject1<FooNames,FooTypes>;

2. Använd fula nycklar för fälttypabstraktion istället

interface FooTypes2 {
  [FooNames.first]: string,
  [FooNames.second]: number,
  [FooNames.third]: boolean,
}

// SUCCESS Generic backend object type
interface BackendObject2<FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {
    [k in FieldNames]: FieldTypes[k]
  }
}
// ... for our example type Foo:
type FooBackend = BackendObject2<FooNames, FooTypes2>
let someFooBackendObject: FooBackend = {
  fields: {
    [FooNames.first]: 'something',
    [FooNames.second]: 5,
    [FooNames.third]: true
  }
}

// HOWEVER....  Generic frontend object FAILURE
interface FrontendObject2<NiceFieldNames extends string, FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {
    // Invalid syntax; no way to access enum and no matching of k
    [k in NiceFieldNames]: FieldTypes[FieldNames.k]
  }
}

3. Kombinera objektabstraktion som tupler, med användning av strängliteraltyper

// Field names and types in one interface:
interface FooTuples {
  first: ['FIRST_FIELD', string]
  second: ['TT_FIELD_SECOND', number]
  third: ['third_field_33', boolean]
}


// FAILURE
interface BackendObject3<TypeTuples> {
  fields: {
    // e.g. { first: string }
    // Invalid syntax for indexing
    [k in TypeTuples[1] ]: string|number|boolean
  }
}

4. Ett "fields"-objekt per typ

// Abstractions for field names and types combined into a single object
interface FieldsObject {
  fields: {
    [niceName: string]: {
      dbName: string,
      prototype: string|boolean|number // used only for indicating type
    }
  }
}
let FooFields: FieldsObject = {
  fields: {
    first: {
      dbName: 'FIRST_FIELD',
      prototype: ''      
    },
    second: {
      dbName: 'TT_FIELD_SECOND',
      prototype: 0
    },
    third: {
      dbName: 'third_field3',
      prototype: true,
    }
  }
}

// FAIL: Frontend object type definition 
interface FrontendObject3<FieldsObject extends string> {
  fields: {
    // Cannot access nested type of 'prototype'
    [k in keyof FieldsObject]: FieldsObject[k][prototype];  
  }
}
// FAIL: Backendobject type definition
interface BackendObject3<FieldsObject extends string> {
  fields: {
    [k in keyof ...]:  // No string literal type for all values of 'dbName'
  }
}

Svar #1

Jag tror att följande borde fungera för dig:

type BackendObject<
  E extends Record<keyof E, keyof any>,
  I extends Record<keyof E, any>
  > = {
    fields: {
      [P in E[keyof E]]: I[{
        [Q in keyof E]: E[Q] extends P ? Q : never
      }[keyof E]]
    }
  }

interface FooBackendObject extends
  BackendObject<typeof FooNames, FooTypes> { }

Typen BackendObject<E, I> är inte ett gränssnitt, men du kan deklarera ett gränssnitt för vilka specifika konkreta värden som helst av E och I som i FooBackendObject ovan. Så i BackendObject<E, I> förväntar vi oss att E är mappningen till nycklar (representerad i FooBackendObject av FooNames värde, vars typ är typeof FooNames... du kan inte bara använda FooNames typen här, eftersom den inte innehåller mappningen), och I är mappningen till värden (representerad i FooBackendObject av gränssnittet FooTypes).

De använda mappade/betingade typerna kan vara lite fula, men det här är vad vi gör: först kommer nycklarna i fields-objektet från värdena i E (E[keyof E]). För varje nyckel P i det, hittar vi nyckeln i E som motsvarar den ({[Q in keyof E]: E[Q] extends P ? Q : never}[keyof E]), och använder sedan den nyckeln för att indexera i I för värdetypen.

Låt oss förklara {[Q in keyof E]: E[Q] extends P ? Q : never}[keyof E] mer fullständigt. Generellt kommer en typ som {[Q in keyof E]: SomeType<Q>}[keyof E] att vara unionen av SomeType<Q> för alla Q i keyof E. Du kan omvandla det till en konkret typ om det är mer meningsfullt... om E är {a: string, b: number}, då kommer {[Q in keyof E]: SomeType<Q>} att vara {a: SomeType<'a'>, b: SomeType<'b'>}, och sedan tittar vi upp dess värden vid nycklar keyof E, vilket är {a: SomeType<'a'>, b: SomeType<'b'>}['a'|'b'], vilket blir SomeType<'a'> | SomeType<'b'>. I vårt fall är SomeType<Q> E[Q] extends P ? Q : never, vilket utvärderas till Q om E[Q] matchar P, och never annars. Så vi får unionen av Q-värden i keyof E för vilka E[Q] matchar P. Det borde finnas bara ett av dessa (om enum inte har två nycklar med samma värde).

Det kan vara användbart för dig att gå igenom övningen att manuellt utvärdera BackendObject<typeof FooNames, FooTypes> för att se det hända.

Du kan verifiera att det beter sig som önskat. Hoppas det hjälper. Lycka till!

Relaterade artiklar