javascript - Typescript y búsquedas en literales de objetos
Ejemplo mínimo reproducible del problema
Tengo un objeto literal:
const map = (filter: Filter) => {
return {
[Filter.USERS]: {
fetch: async () => getUsers(),
set: setUsers,
},
[Filter.FIRMS]: {
fetch: async () => getFirms(),
set: setFirms,
}
}[filter];
Y tengo mi estado definido así:
const [firms, setFirms] = useState<Maybe<FirmsResponse>>(null);
const [users, setUsers] = useState<Maybe<UsersResponse>>(null);
const [filter, setFilter] = useState<Filter>(Filter.USERS)
Quiero usar mi objeto:
const currentFilter = map(filter);
const response = currentFilter.fetch();
currentFilter.set(response.data);
// ^^^^^^^^^^^^^^ Here I get a long TS error which boils down to:
// "Type 'UsersResponse' provides no match for the signature
// '(prevState: Maybe<FirmsResponse>): Maybe<FirmsResponse>'"
¿Hay alguna manera de configurar mis datos así o estoy obligado a usar algo como:
if (filter === Filter.USERS) {
setUsers(
(response.data as unknown) as Users
);
} else if ...
Respuesta #1
En interés de tener un IntelliSense legible para humanos, voy a hacer un poco de refactorización de tu código de ejemplo. No cambiará ninguno de los comportamientos ni los tipos, aparte de los detalles de implementación.
Primero voy a anotar tus tipos de retorno getUsers y getFirms como UsersResponse y FirmsResponse:
const getUsers = (): UsersResponse => [{ id: 1, name: "John" }];
const getFirms = (): FirmsResponse => [{ id: 1, firmName: "John's Waffles" }];
Además, voy a definir una interfaz FetchSet<T> para representar el par fetch/set para un tipo de respuesta particular T:
interface FetchSet<T> {
fetch: () => T;
set: React.Dispatch<React.SetStateAction<Maybe<T>>>
}
Y refactoriza map() para que se defina en términos de estos tipos:
const usersFetchSet: FetchSet<UsersResponse> = { fetch: getUsers, set: setUsers };
const firmsFetchSet: FetchSet<FirmsResponse> = { fetch: getFirms, set: setFirms };
const mapper = {
[Filter.USERS]: usersFetchSet,
[Filter.FIRMS]: firmsFetchSet
}
const map = (filter: Filter) => {
return mapper[filter];
};
Está bien, hasta ahora todo es igual. Veamos tu error:
const currentFilter = map(filter);
// const currentFilter: FetchSet<UsersResponse> | FetchSet<FirmsResponse>
const response = currentFilter.fetch();
// const response: UsersResponse | FirmsResponse
currentFilter.set(response); // error!
// Argument of type 'UsersResponse | FirmsResponse' is not
// assignable to parameter of type
// '(
// (FirmsResponse | ((prevState: Maybe<FirmsResponse>) => Maybe<FirmsResponse>)) &
// (UsersResponse | ((prevState: Maybe<...>) => Maybe<...>))
// ) | null'
Entonces, el compilador está viendo la entrada como un tipo unión UsersResponse | FirmsResponse, pero estaba esperando que la entrada fuera un tipo intersección en su lugar; es decir, UsersResponse & FirmsResponse.
El mensaje de error entra en un poco más de detalle porque los métodos set() también aceptan null y también aceptan algunos callbacks, pero no estás pasando null ni callbacks, así que podemos simplificar el error a algo como esto:
// Argument of type 'UsersResponse | FirmsResponse' is not assignable to
// parameter of type 'UsersResponse & FirmsResponse'
Entonces, ¿por qué está sucediendo esto y cómo podemos abordarlo?
Un problema importante es que la firma de llamada inferida de tu función map es esta:
// const map: (filter: Filter) => FetchSet<UsersResponse> | FetchSet<FirmsResponse>
No importa qué Filter pongas, el compilador solo entiende que la unión está saliendo:
const fsOops = map(Filter.USERS);
// const fsOops: FetchSet<UsersResponse> | FetchSet<FirmsResponse>
Para todo lo que el compilador sabe, cuando llamas a map(Filter.USERS), saldrá un FetchSet<FirmsResponse>. Si quieres que el compilador sepa que hay una relación entre el Filter particular que pones y el tipo particular que sale, necesitarás anotar la firma de la llamada apropiadamente.
En este caso, puedes hacer que map sea una función genérica, donde la entrada filter sea de tipo genérico F que está restringido a Filter:
const map = <F extends Filter>(filter: F) => {
return mapper[filter];
};
/* const map: <F extends Filter>(filter: F) => {
0: FetchSet<UsersResponse>;
1: FetchSet<FirmsResponse>;
}[F] */
Ahora la firma de llamada inferida es lo suficientemente genérica como para entender lo que sucede cuando se introduce un valor particular:
const fsOkay = map(Filter.USERS);
// const fsOkay: FetchSet<UsersResponse>
Así que ¡hurra, hemos resuelto el problema, ¿verdad? Veamos:
const currentFilter = map(filter);
// const currentFilter: FetchSet<UsersResponse> | FetchSet<FirmsResponse>
const response = currentFilter.fetch();
// const response: UsersResponse | FirmsResponse
currentFilter.set(response); // error!
// still Argument of type 'UsersResponse | FirmsResponse' is not assignable to
// parameter of type 'UsersResponse & FirmsResponse'
No, nada ha cambiado. ¿Pero por qué?
El problema es el tema planteado en microsoft/TypeScript#30581. El valor filter que pasas a map() es un tipo unión; es Filter.USERS | Filter.FIRMS. Y así currentFilter también es un tipo unión: FetchSet<UsersResponse> | FetchSet<FirmsResponse>. Por lo tanto, el tipo de response también es una unión, UsersResponse | FirmsResponse, y el tipo de currentFilter.set también es una unión, React.Dispatch<React.SetStateAction<Maybe<UsersResponse>>> | React.Dispatch<React.SetStateAction<Maybe<FirmsResponse>>>.
Todo esto es correcto, pero hay un problema. El compilador no tiene forma de modelar que la unión currentFilter.set está correlacionada con la unión response. Un ser humano podría evaluar esta situación imaginando primero que filter es Filter.USERS, y determinando los tipos resultantes de currentFilter.set y response, y viendo que coinciden. Y luego este ser humano imaginaría que filter es Filter.FIRMS, determinaría los tipos resultantes de currentFilter.set y Filter.USERS, y viendo que ellos coinciden, y luego concluiría que por lo tanto el bloque de código está bien, ya que coincide en cada uno de los dos casos posibles.
Pero el compilador no analiza el mismo bloque de código múltiples veces de esta manera, y no ve solo dos casos posibles. Analiza el bloque una vez. currentFilter.set tiene dos tipos posibles, y response tiene dos tipos posibles, y por lo tanto el par de ellos tiene cuatro tipos posibles. Por todo lo que puede deducir de su análisis, currentFilter.set es un React.Dispatch...<UsersResponse>>> mientras que response es un FirmsResponse. Y por eso se queja.
Dado que currentFilter.set es una unión de funciones, antes de TypeScript 3.3 el compilador simplemente se rendía y decía que no se podía llamar. TypeScript 3.3 introdujo soporte para llamar a uniones de funciones, pero requiere que pases un parámetro que sea la intersección de los parámetros de cada función. Las notas de la versión explican por qué las uniones de funciones solo pueden tomar de manera segura intersecciones de parámetros, así que no insistiré en el punto aquí.
Una vez más, el compilador no puede rastrear las correlaciones entre múltiples expresiones de tipos unión, por lo que no puede verificar que el código sea seguro porque está protegiendo contra escenarios imposibles de correlación cruzada. El problema en GitHub es principalmente solo una descripción de este problema, junto con algunas soluciones alternativas y una lista larga de casos de uso. Esta pregunta es otro de esos casos de uso.
Así que no hay un buen soporte para lo que estás haciendo. Las dos soluciones generales que conozco son: obtener seguridad de tipos pero a costa de escribir código poco escalable y redundante, o tener código conveniente pero renunciar a algo de seguridad de tipos.
El código seguro pero redundante se ve así:
switch (filter) {
case Filter.FIRMS: {
const currentFilter = map(filter);
const response = currentFilter.fetch();
currentFilter.set(response); // no error
break;
}
case Filter.USERS: {
const currentFilter = map(filter); // union
const response = currentFilter.fetch();
currentFilter.set(response); // no error
break;
}
}
Aquí estamos haciendo que el compilador haga lo que haría el humano: imaginar cada caso para filter y verificar que el mismo bloque de código funcione en ambos casos. Ahora el compilador está satisfecho con esto. Pero repetirse para apaciguar al compilador probablemente solo sea deseable si valoras la seguridad de tipos en un grado extremo.
La solución alternativa que normalmente sugiero es renunciar a cierta seguridad de tipos:
currentFilter.set(response as FirmsResponse & UsersResponse); // no error
Si el compilador solo acepta un FirmsResponse & UsersReponse sin quejarse, simplemente afirmaremos que response es una de esas cosas. Sabemos que en realidad no lo es, pero esta pretensión suprime el error, y el compilador está contento. Si seguimos por este camino, podríamos ir hasta el final y simplemente afirmarlo al tipo any:
currentFilter.set(response as any); // no error
Esto es ciertamente conveniente, pero siempre que uses una afirmación de tipo, deberías tener cuidado de no haber hecho algo inseguro, ya que el compilador ya no puede protegerte:
// don't do this
const randomFilter = Math.random() < 0.5 ? Filter.FIRMS : Filter.USERS;
const randomResponse = map(randomFilter).fetch()
currentFilter.set(randomResponse as FirmsResponse & UsersResponse); // no error
Aquí estoy lanzando una moneda en tiempo de ejecución y pasando randomResponse a currentFilter.set. Mi aserción de tipo ha suprimido el error, aunque ahora realmente es posible que haya pasado la respuesta incorrecta. Así que, de nuevo: ten cuidado de no hacer algo como esto.
Enlace de Playground al código
