reactjs - Requerir que al menos una de las dos propiedades sea proporcionada en props
Usando TypeScript, es posible crear un tipo en el que al menos una de dos propiedades sea requerida. Hay (al menos) dos formas de hacerlo: usando tipos de unión y una solución genérica algo más compleja. Estas dos soluciones funcionan bien. Puedo crear y obtener el error de compilador deseado cuando no se especifica ninguna de las dos propiedades.
Sin embargo, al usar el tipo final para especificar los props en un componente de React, el compilador no puede encontrar las dos propiedades "requeridas". Aquí hay un ejemplo simplificado:
interface Base {
text?: string;
}
interface WithId extends Base {
id: number;
}
interface WithToken extends Base {
token: string;
}
type Identifiable = WithId | WithToken | (WithId & WithToken);
//OK
const item: Identifiable = {
text: "some text"
id: 4
};
//OK
const item: Identifiable = {
token: "some token"
};
//Error
const item: Identifiable = {
text: "some text"
};
//OK
export class MyComponent extends React.Component<Identifiable, any> {
render() {
//Error (see below)
return <p>{this.props.id}</p>
}
}
El error recibido al intentar acceder a una de las dos propiedades requeridas (independientemente de dónde esté dentro de la clase) se ve algo así:
Property 'id' does not exist on type '(Readonly<{children?: ReactNode;}> & Readonly<WithId>) | (Readonly<{children?: ReactNode;}> & Readonly<WithToken>) | (Readonly<{children?: ReactNode;}> & Readonly<WithId & WithToken>)'
Property 'id' does not exist on type '(Readonly<{children?: ReactNode;}> & Readonly<WithToken>'.
¿Hay alguna manera de solucionar esto y hacer que el compilador entienda estos requisitos?
Nota: usando TypeScript 3.0.1, React 16.4.2, con las últimas tipificaciones disponibles hasta la fecha.
Respuesta #1
No puedes acceder a los campos de una unión que no son comunes a todos los miembros de la unión.
Las opciones que vienen a la mente son protecciones de tipo o usar un tipo diferente para las props cuando realmente las usas.
La opción simple es un type-guard, solo verifica la existencia de la propiedad usando un type-guard in:
export class MyComponent extends React.Component<Identifiable, any> {
render() {
if ('id' in this.props) {
// Ok
return <p>{this.props.id}</p>
}
}
}
La segunda solución más complicada es crear un tipo a partir de Identifiable que sea la misma unión, pero con cada miembro de la unión aumentado con los campos que faltan de toda la unión, con los campos faltantes siendo opcionales y de tipo undefined. Básicamente queremos pasar de { id: string } | { token: string } a { id: string; token?:undefined } | { token: string; id?:undefined }. Esto permitiría acceder a cualquier campo de la unión mientras se mantiene la seguridad de tipos bajo strictNullChecks.
Este nuevo tipo seguirá siendo compatible con tus props originales, y por lo tanto solo necesitaríamos una variable local de este tipo y asignarla con las props de la clase.
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type FlattenUnionHelper<T, TAll> = T extends any ? T & Partial<Record<Exclude<keyof TAll, keyof T>, undefined>> : never;
type FlattenUnion<T> = FlattenUnionHelper<T, UnionToIntersection<T>>
export class MyComponent extends React.Component<Identifiable, any> {
render() {
let props: FlattenUnion<Identifiable> = this.props
return <p>{props.id}</p>
}
}
Creo que puedes usar la versión aplanada directamente como props, pero deberías probar que se cumplan todos tus requisitos; por lo que probé, funciona bien
export class MyComponent extends React.Component<FlattenUnion<Identifiable>, any> { /*...*/}
let f = <MyComponent id={0} />
let f2 = <MyComponent token="" />
let f3 = <MyComponent id={0} token="" />
let f4 = <MyComponent text="" /> // error
Respuesta #2
La idea común presentada en las otras respuestas, de que usar guardianes de tipo puede satisfacer al compilador, es correcta y funciona bien. Sin embargo, inspirado por cómo funcionan los tipos de unión y por las respuestas referenciadas en la pregunta, he decidido intentar construir un tipo genérico que funcione sin requerir guardianes de tipo (por favor, tenga en cuenta que, aunque los guardianes de tipo no son necesarios para el compilador de TypeScript, verificar si una propiedad existe en un objeto en JavaScript todavía debe hacerse en tiempo de ejecución, dependiendo de su escenario).
Esta es la solución que finalmente funcionó para mí:
interface Base {
text?: string;
id?: number;
token?: string;
}
type RequireProperty<T, Prop extends keyof T> = T & {[key in Prop]-?:T[key]};
type RequireIdOrToken = RequireProperty<Base, "id"> | RequireProperty<Base, "token">;
//Error
const o1: RequireIdOrToken = { };
//Error
const o2: RequireIdOrToken = {
text: "some text"
};
//OK
const o3: RequireIdOrToken = {
id: 4
};
//OK -> no type guard needed
o3.token = "a new token";
//OK
const o4: RequireIdOrToken = {
token: "some token"
};
La idea general es tener un tipo de unión entre dos tipos que tienen las mismas propiedades. Esto evita la necesidad de usar guardias de tipo. En cada uno de los dos tipos, una u otra propiedad se marca como obligatoria. Esto requiere que ambas propiedades se marquen como opcionales en la interfaz "base".
El ejemplo podría hacerse genérico
type RequireProperty<T, Prop extends keyof T> = T & {[key in Prop]-?:T[key]};
type RequireTwoProperties<T, Prop1 extends keyof T, Prop2 extends keyof T>
= RequireProperty<T, Prop1> | RequireProperty<T, Prop2>;
y así se extendió para aceptar múltiples propiedades. Un tipo genérico que acepte una "lista" de nombres de propiedades (subconjunto de keyof T) y lo transforme en un tipo unión de tipo RequireProperty para cada propiedad especificada, sería la solución perfecta. No sé si eso es actualmente posible.
