javascript - TS: Inferir el tipo literal basado en métodos encadenados
Solo queda una cosa para que pueda desbloquear mi flujo y publicar la primera versión de prueba de mi biblioteca de validación de formularios.
Tengo el siguiente código (por supuesto estoy omitiendo muchas cosas para que no sea demasiado grande)
interface Validation {
name: string
message: string
params?: Record<string, any>
test: (value: any, params?: any) => boolean
}
class MixedSchema<T> {
type!: T
validations: Validation[] = []
required(message?: string) {
this.validations.push({
name: 'required',
message: message,
test: (value: any) => {
return value === '' ? false : true
},
})
return this
}
oneOf(arrayOfValues: any[], message?: string) {
type Params = { arrayOfValues: any[] }
this.validations.push({
name: 'oneOf',
params: { arrayOfValues },
message: message,
test: (value: any, params: Params) => {
return params.arrayOfValues.includes(value)
},
})
return this
}
}
class StringSchema extends MixedSchema<string> {
email() {
// this.validations.push({ ... })
return this
}
maxWords(maxWords: number, message?: string) {
// this.validations.push({ ... })
return this
}
}
class NumberSchema extends MixedSchema<number> {
min(min: number, message?: string) {
// this.validations.push({ ... })
return this
}
round(type: 'toUp' | 'toDown' | 'closer') {
// this.validations.push({ ... })
return this
}
}
const schema = {
string() {
return new StringSchema()
},
number() {
return new NumberSchema()
},
// array, file, etc..
}
const form = {
email: schema.string().required(),
age: schema.number().min(18),
gender: schema.string().oneOf(['male', 'female']).required(),
color: schema.string().oneOf(['red', 'blue', 'green']),
}
type InferType<T> = {
[P in keyof T]: T[P] extends MixedSchema<infer TS> ? TS : never
}
type Form = InferType<typeof form>
Entonces obtengo el siguiente resultado
Pero necesito obtener la escritura más real posible, quiero decir, similar al esquema definido para form, ejemplo
interface HowNeedsToBe {
email: string
age: number | undefined
gender: 'male' | 'female'
color: 'red' | 'blue' | 'green' | undefined
}
Creo que la lógica es algo así: en ausencia del required, pone un undefined y si hay oneOf, sustituye el argumento con el T del MixedSchema<T>, pero no sé cómo enviar esto de vuelta hasta MixedSchema<T>, de hecho creo que esta lógica es un desastre.
Ya investigué sobre map y genéricos en TypeScript, pero confieso que cuando se trata de ponerlo en práctica, no sale nada bueno.
Aquí está el playground de TS si quieres probar.
Respuesta #1
Conceptualmente, deseas que los métodos required() y oneOf() reduzcan T; esto sería lo suficientemente fácil de tipar (aunque necesitas afirmaciones de tipo para evitar errores del compilador, ya que el compilador no puede verificar que realmente hayas hecho la reducción necesaria). Por lo tanto, required(), llamado sobre un MixedSchema<T>, debería devolver un MixedSchema<Exclude<T, undefined>> (usando el tipo de utilidad Exclude para eliminar undefined de cualquier miembro de la unión de T). Y oneOf() debería ser genérico en el tipo de elemento U de los elementos de arrayOfValues, y debería devolver un MixedSchema<U | Extract<undefined, T>> (usando el tipo de utilidad Extract para mantener undefined si T puede ser undefined).
Aquí se muestra cómo podría verse (implementaciones omitidas por brevedad):
declare class MixedSchema<T> {
type: T
validations: Validation[];
required(message?: string): MixedSchema<Exclude<T, undefined>>;
oneOf<U extends T>(arrayOfValues: U[], message?: string):
MixedSchema<U | Extract<undefined, T>>
}
Desafortunadamente, el hecho de que estés subclasificando MixedSchema está complicando las cosas; quieres decir que, por ejemplo, un StringSchema debería seguir siendo un StringSchema de algún tipo después de llamar a required(); no debería ampliarse de nuevo a MixedSchema:
declare class StringSchema<T extends string | undefined> extends MixedSchema<T> {
email(): this;
maxWords(maxWords: number, message?: string): this;
}
declare class NumberSchema<T extends number | undefined> extends MixedSchema<T> {
min(min: number, message?: string): this;
round(type: 'toUp' | 'toDown' | 'closer'): this;
}
const s = new StringSchema() // StringSchema<string | undefined>
s.email(); // okay
const t = s.required(); // MixedSchema<string>
t.email(); // error! Property 'email' does not exist on type 'MixedSchema<string>';
Así que necesitaremos algo más complicado.
La "respuesta correcta" aquí es usar los llamados tipos de orden superior del tipo solicitado (pero no implementado) en microsoft/TypeScript#1213. Te gustaría decir algo como: el método required() de MixedSchema<T> debería devolver this<Exclude<T, undefined>>, donde de alguna manera estás tratando this como un tipo que toma un parámetro genérico. Entonces, si this es un StringSchema<T>, entonces debería ser StringSchema<Exclude<T, undefined>>. Pero no hay soporte directo para esto.
En cambio, necesitamos simularlo, y todas esas simulaciones implicarán cierta cantidad de "registrar" los tipos que nos gustaría poder tratar como un genérico de un genérico:
type Specify<C extends MixedSchema<any>, T> =
C extends NumberSchema<any> ? NumberSchema<Extract<T, number | undefined>> :
C extends StringSchema<any> ? StringSchema<Extract<T, string | undefined>> :
MixedSchema<T>;
Hemos enumerado todas las subclases de MixedSchema que nos interesan y descrito cómo especificar sus parámetros de tipo. Así que, aunque no podemos escribir this<Exclude<T, undefined>>, sí podemos escribir Specify<this, Exclude<T, undefined>> y obtener el mismo efecto.
Aquí está la nueva implementación de MixedSchema:
declare class MixedSchema<T> {
type: T
validations: Validation[];
required(message?: string): Specify<this, Exclude<T, undefined>>;
oneOf<U extends T>(arrayOfValues: U[], message?: string):
Specify<this, U | Extract<undefined, T>>
}
Y podemos verificar que ahora se comporta adecuadamente en las subclases:
const s = new StringSchema() // StringSchema<string | undefined>
s.email(); // okay
const t = s.required(); // StringSchema<string>
t.email(); // okay
Asegurémonos de que los tipos se infieran como te gustaría:
const form = {
email: schema.string().required(),
age: schema.number().min(18),
gender: schema.string().oneOf(['male', 'female']).required(),
color: schema.string().oneOf(['red', 'blue', 'green']),
}
/* const form: {
email: StringSchema<string>;
age: NumberSchema<number | undefined>;
gender: StringSchema<"male" | "female">;
color: StringSchema<"red" | "blue" | "green" | undefined>;
} */
Eso es una buena señal; el parámetro de tipo genérico para cada campo se ha especificado de la manera correcta. Y así tu InferType debería poder capturar los tipos de esos campos:
type Form = InferType<typeof form>
/* type Form = {
email: string;
age: number | undefined;
gender: "male" | "female";
color: "red" | "blue" | "green" | undefined;
} */
¡Se ve bien!
Enlace de Playground al código
