Tipos relativos de TypeScript en una interfaz
Tengo una interfaz, así:
type DataType = "string" | "number" | "boolean" | "timestamp";
interface Schema {
name?: string
...
columns: {
[column: string]: {
type: DataType
...
foreignKey?: {
schema: Schema
...
join: {from: string, to: string}
}
}
}
}
Y tengo varios esquemas definidos basados en esta interfaz, como estos por ejemplo:
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'}
}
},
}
}
Como puedes ver, en el schemaC.col_C2.foreignKey, he pasado el schemaA como el esquema. La intención es bastante obvia, estoy intentando definir un modelo de datos relacionado.
Mi problema es cómo verificar tipos en el join. Específicamente, quiero que el join.from esté restringido a las columnas de su esquema padre (aquí schemaC) y que el join.to esté restringido a las columnas del esquema proporcionado (aquí schemaA). Pero hasta donde entiendo, para hacer esto, necesito poder acceder a algún tipo de argumento this, para indicarle a TypeScript algo como:
join: {from: keys of this.columns} // (the join.to is even more complicated)
Enlace del parque
Entonces, ¿qué puedo hacer? ¿Es esto siquiera posible?
¿Estoy equivocado al pensar que esto debería ser posible? Parece bastante útil.
Respuesta #1
No hay un tipo específico de Schema que satisfaga tus necesidades, ya que las propiedades válidas from y to dependen de las claves de columns tanto del objeto actual como del de la propiedad schema. Esto necesita representarse como un tipo genérico como Schema<T> donde T es el tipo de los datos que estás representando. Idealmente, te gustaría que Schema significara "Schema<T> para algun T que no me importa", pero expresarlo directamente requeriría genéricos cuantificados existencialmente como se solicitó en microsoft/TypeScript#14446. En lugar de preocuparse por intentar emular eso, podemos definir un Schema<T> genérico y luego escribir una función auxiliar que infiera T a partir de un valor de objeto esquema en particular, para que no tengas que escribirlo tú mismo.
Aquí hay un enfoque:
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] };
};
} };
};
Aquí estamos diciendo que la propiedad columns de un Schema<T> contiene una propiedad correspondiente a cada propiedad de T; es un tipo mapeado sobre cada tipo de clave K en las claves de T. Esta propiedad contiene una propiedad type, así como una propiedad opcional foreignKey que contiene un objeto Schema. Dado que presumiblemente representa un objeto Schema para ese tipo de propiedad, que es T[K], entonces es de tipo Schema<T[K]>. Finalmente, la propiedad join.from debe elegirse del conjunto de claves de T, mientras que la propiedad join.to debe elegirse del conjunto de claves de T[K].
Entonces la función auxiliar se ve así
const asSchema = <T,>(schema: Schema<T>) => schema;
Es solo una función de identidad genérica que devuelve su entrada. Pero la usas para que, en lugar de escribir const foo: Schema<SomeComplicatedThing>={...}, escribas const foo=asSchema({...}) y dejes que el compilador infiera SomeComplicatedThing si puede.
Vamos a probarlo:
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;
}> */
Aquí, schemaA se infiere como tipo Schema<{col_A1: unknown; col_A2: unknown; col_A3: unknown}>. Este tipo es suficiente para tus propósitos, al menos con respecto al ejemplo. Podría ser agradable si fuera Schema<{col_A1: string, col_A2: number, col_A3: boolean}>, pero no te importaban mucho los tipos de valor antes, así que está fuera del alcance de la pregunta tal como se formuló. En cierto sentido, realmente solo queremos rastrear los tipos de claves y no los tipos completos de objetos, pero es conveniente tener un tipo de objeto aquí incluso con propiedades unknown porque nos permitirá representar los tipos recursivos que te interesan, mientras que solo los conjuntos de claves no lo permitirían.
Más pruebas:
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;
};
}> */
Nuevamente, esta es una inferencia razonable. El tipo de la propiedad col_C2 ahora es más específico que simplemente desconocido, y corresponde al parámetro de tipo en el esquema para schemaA.
Y si cometemos un error, se nos alertará:
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"'
},
},
},
});
que es todo el objetivo de este ejercicio.
Enlace de Playground al código
