Typescript relativa typer i gränssnitt


Jag har ett gränssnitt, ungefär så här:

type DataType = "string" | "number" | "boolean" | "timestamp";
interface Schema {
  name?: string
  ...
  columns: {
    [column: string]: {
      type: DataType
      ...
      foreignKey?: {
        schema: Schema
        ...
        join: {from: string, to: string}
      }
    }
  }
}

Och jag har flera scheman definierade baserade på detta gränssnitt, som dessa till exempel:

const schemaA: Schema = {
  name: 'entity_A',
  columns: {
    col_A1: {
      type: 'string'
    },
    col_A2: {
      type: 'number'
    },
    col_A3: {
      type: 'boolean'
    },
  }
}

const schemaB: Schema = {
  name: 'entity_B',
  columns: {
    col_B1: {
      type: 'timestamp'
    },
    col_B2: {
      type: 'string'
    },
    col_B3: {
      type: 'string'
    },
  }
}

const schemaC: Schema = {
  name: 'entity_C',
  columns: {
    col_C1: {
      type: 'string'
    },
    col_C2: {
      type: 'string',
      foreignKey: {
        schema: schemaA,
        join: {from: 'col_C1', to: 'col_A1'}
      }
    },
  }
}

Som du kan se, i schemaC.col_C2.foreignKey, har jag skickat schemaA som schemat. Avsikten är ganska uppenbar, jag försöker definiera en relaterad datamodell.

Mitt problem är hur man typkontrollerar join. Specifikt vill jag att join.from ska begränsas till kolumnerna i dess överordnade schema (här schemaC) och att join.to ska begränsas till kolumnerna i det angivna schemat (här schemaA). Men såvitt jag förstår, för att göra detta, behöver jag kunna komma åt någon slags this-argument, för att tala om för typescript något i stil med:

join: {from: keys of this.columns}    // (the join.to is even more complicated)

Lekplatslänk

Så, vad kan jag göra? Är det här ens möjligt?

Har jag fel som tror att detta borde vara möjligt? Det verkar ganska användbart.

Svar #1

Det finns ingen specifik typ Schema som uppfyller dina behov, eftersom de giltiga from och to egenskaperna beror på nycklarna i columns för både det aktuella objektet och det som finns i schema egenskapen. Detta behöver representeras som en generisk typ som Schema<T> där T är typen av data du representerar. Idealiskt skulle du vilja att Schema betyder "Schema<T> för något T jag inte bryr mig om", men att uttrycka det direkt skulle kräva existentiellt kvantifierade generics som begärt i microsoft/TypeScript#14446. Istället för att oroa sig för att försöka emulera det kan vi istället definiera generisk Schema<T> och sedan skriva en hjälpfunktion som infererar T från ett särskilt schemaobjektvärde, så att du inte behöver skriva ut det själv.

Här är ett tillvägagångssätt:

type Schema<T> = {
    name?: string;
    // ...
    columns: { [K in keyof T]: {
        type: DataType;
        // ...
        foreignKey?: {
            schema: Schema<T[K]>;
            // ...
            join: { from: keyof T; to: keyof T[K] };
        };
    } };
};

Här säger vi att columns-egenskapen hos en Schema<T> innehåller en egenskap som motsvarar varje egenskap i T; det är en mappad typ över varje nyckeltyp K i nycklarna av T. Denna egenskap innehåller en type-egenskap, samt en valfri foreignKey-egenskap som innehåller ett Schema-objekt. Eftersom den förmodligen representerar ett Schema-objekt för den egenskapstypen, som är T[K], så är den av typen Schema<T[K]>. Slutligen måste join.from-egenskapen väljas från mängden av nycklar i T, medan join.to-egenskapen måste väljas från mängden av nycklar i T[K].

Då ser hjälpfunktionen ut så här

const asSchema = <T,>(schema: Schema<T>) => schema;

Det är bara en generell identitetsfunktion som returnerar sitt indata. Men du använder den så att istället för att skriva const foo: Schema<SomeComplicatedThing>={...}, skriver du const foo=asSchema({...}) och låter kompilatorn härleda SomeComplicatedThing om den kan.


Låt oss prova det:

const schemaA = asSchema({
    name: "entity_A",
    columns: {
        col_A1: {
            type: "string",
        },
        col_A2: {
            type: "number",
        },
        col_A3: {
            type: "boolean",
        },
    },
});
/* const schemaA: Schema<{
    col_A1: unknown;
    col_A2: unknown;
    col_A3: unknown;
}> */

Här är schemaA härledd som typen Schema<{col_A1: unknown; col_A2: unknown; col_A3: unknown}>. Denna typ är tillräcklig för dina behov, åtminstone med avseende på exemplet. Det skulle kunna vara trevligt om det var Schema<{col_A1: string, col_A2: number, col_A3: boolean}>, men du brydde dig inte så mycket om värdetyperna tidigare, så det ligger utanför frågans omfattning. På ett sätt vill vi egentligen bara spåra nyckeltyper och inte fullständiga objekttyper, men det är praktiskt att ha en objekttyp här även med unknown-egenskaper eftersom det låter oss representera de rekursiva typerna du bryr dig om, medan endast nyckelset inte skulle göra det.

Fler tester:

const schemaC = asSchema({
    name: "entity_C",
    columns: {
        col_C1: {
            type: "string",
        },
        col_C2: {
            type: "string",
            foreignKey: {
                schema: schemaA,
                join: { from: "col_C1", to: "col_A1" },
            },
        },
    },
});
/* const schemaC: Schema<{
    col_C1: unknown;
    col_C2: {
        col_A1: unknown;
        col_A2: unknown;
        col_A3: unknown;
    };
}> */

Återigen är detta en rimlig slutsats. Typen på col_C2-egenskapen är nu mer specifik än bara unknown, och motsvarar typparametern i schemat för schemaA.

Och om vi gör ett misstag kommer vi att bli varnade för det:

const schemaCOops = asSchema({
    name: "entity_C",
    columns: {
        col_C1: {
            type: "string",
        },
        col_C2: {
            type: "string",
            foreignKey: {
                schema: schemaA,
                join: { from: "col_C1", to: "col_B2" }, // error!
                // -------------------> ~~
                // Type '"col_B2"' is not assignable to 
                // type '"col_A1" | "col_A2" | "col_A3"'
            },
        },
    },
});

vilket är hela poängen med denna övning.

Playground-länk till kod