Los desarrolladores de aplicaciones cliente trabajan a diario con API. Es una buena práctica estandarizar las respuestas de las API en función del éxito de las operaciones o de la lógica empresarial. Normalmente, una respuesta incluye campos estándar como estado, error, etc.
Con estos campos estándar, los desarrolladores pueden reaccionar al estado de la operación y construir más interacciones del usuario con la aplicación. Si el registro se realiza correctamente, el formulario debería cerrarse y debería mostrarse un mensaje de éxito. Sin embargo, deben mostrarse errores de validación en el formulario si los datos tienen un formato incorrecto.
Esto plantea la cuestión de cómo describir de forma cómoda, rápida y flexible los tipos de respuesta en un proyecto.
El problema que me encontré
A veces, los tipos de respuesta en un proyecto se describen utilizando sólo un tipo con muchos parámetros opcionales. En la mayoría de los casos, esto puede ser suficiente, y TypeScript sugerirá estos parámetros al escribir código, pero se necesitarán comprobaciones adicionales de la presencia de estos parámetros. Aquí hay un ejemplo de un tipo de este tipo:
export enum ApiStatus {
OK = `ok`,
ERROR = `error`,
FORM_ERRORS = `form_errors`,
REDIRECT = `redirect`,
}
export type ApiData = {
status: ApiStatus
error?: string
errors?: Record<string, string>
url?: string
}
La única ventaja de este enfoque es su simplicidad. Podemos añadir el tipo ApiData
a cualquier tipo de respuesta, y eso será suficiente.
export type UserProfile = {
id: number
name: string
last_name: string
birthday: string
city: string
}
export type UserProfileResponse = ApiData & {
user: UserProfile
}
// to simulate an API call
const updateProfileAPI = async(data: Partial<UserProfile>): Promise<UserProfileResponse> => {
return Promise.resolve({} as UserProfileResponse)
}
Sin embargo, creo que esta única ventaja se ve contrarrestada por una importante desventaja. La desventaja de este enfoque es la falta de transparencia.
Además, al añadir un tipo de este tipo a los tipos de respuesta, nunca se sabe exactamente cuál será la respuesta para una solicitud específica. Imagina que para una petición POST, puede tener un número limitado de escenarios de respuesta de la API.
Los escenarios podrían ser los siguientes
- una operación correcta con estado:'ok' y algunos datos
- un error de validación con estado:
'form_errors'y errores
: [{}, {}], y eso es todo.
Significa que nunca tendrás el estado: 'redirect' como posible escenario de respuesta en este caso. Además, ¿por qué necesitarías un parámetro errors para la respuesta de peticiones GET?
Resulta que no podemos entender qué opciones exactas de respuesta tenemos con sólo mirar el tipo de respuesta. Para entender todas las posibles variantes de respuesta, es necesario abrir el código de la función que realiza la petición y procesa la respuesta.
Tipos de utilidad para tipos de respuesta
Los inconvenientes descritos anteriormente pueden solucionarse con la ayuda de tipos de utilidad personalizados. Existe un tipo distinto para cada escenario: operación correcta, error del servidor, error de validación o redirección forzada.
Estos tipos pueden utilizarse individualmente o combinados para reflejar todas las opciones de respuesta posibles para una respuesta específica. Cada tipo tendrá un genérico para permitir pasar el tipo de datos correspondiente a esa respuesta.
export enum ApiStatus {
OK = `ok`,
ERROR = `error`,
FORM_ERRORS = `form_errors`,
REDIRECT = `redirect`,
}
export type ApiSuccess<T extends Record<string, unknown> | unknown = unknown> = T & {
status: ApiStatus.OK,
}
export type ApiError<T extends Record<string, unknown> = { error: string } > = T & {
status: ApiStatus.ERROR,
}
export type ApiFormErrors<T extends Record<string, unknown> = { errors: Record<string, string> }> = T & {
status: ApiStatus.FORM_ERRORS,
}
export type ApiRedirect<T extends Record<string, unknown> = { url: string }> = T & {
status: ApiStatus.REDIRECT,
}
export type ApiResponse<T extends Record<string, unknown> | unknown = unknown, K extends Record<string, unknown> = { error: string }, R extends Record<string, unknown> = { errors: Record<string, string> }> = ApiSuccess<T> | ApiError<K> | ApiFormErrors<R>
Además, he creado el tipo general ApiRespinse
, que incluye varios tipos de utilidad. Ahorrará tiempo a la hora de añadir todos los escenarios para cada solicitud POST.
Aquí hay ejemplos de uso de estos tipos de utilidad para diferentes escenarios:
export type FetchUserProfile = ApiSuccess<{
user: UserProfile
}>
export type FetchUserConfig = ApiSuccess<{
config: Record<string, string | number | boolean>
}> | ApiError
export type AddUserSocialNetworkAsLoginMethod = ApiResponse<{
social_network: string,
is_active: boolean
}, { message: string }> | ApiRedirect<{ redirect_url: string }>
Diferencia práctica
A continuación se muestra un ejemplo de tipos para el perfil de usuario y la respuesta devuelta por la función de actualización del perfil de usuario.
const updateProfile = async(): Promise<void> => {
try {
const data = await updateProfileAPI({ name: 'New name' })
// [!!!] Typescript does not highlight that the 'user' property could not exist on the 'data' property
// In the case when data.status === ApiStatus.ERROR|FORM_ERRORS|REDIRECT
console.log(data.user.id)
if (data.status === ApiStatus.OK) {
updatedProfileState(data.user)
return
}
if (data.status === ApiStatus.ERROR) {
// Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
// Type 'undefined' is not assignable to type 'string'.
showNotification('danger', data.error)
return
}
if (data.status === ApiStatus.FORM_ERRORS) {
// Argument of type 'Record<string, string> | undefined' is not assignable to parameter of type 'Record<string, string>'.
// Type 'undefined' is not assignable to type 'Record<string, string>'.
showValidationErrors(data.errors)
return
}
if (data.status === ApiStatus.REDIRECT) {
// Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
// Type 'undefined' is not assignable to type 'string'.
redirect(data.url)
return
}
throw new Error('Something went wrong...')
} catch (err) {
console.error('User: updateProfile - ', err)
}
}
Aquí hay una imagen de cómo TypeScript pelusa este código:
![](https://codigoencasa.com/content/images/2024/06/1_Wh0v-oq7tqygvMV7xAWaGA.webp)
En la imagen, puedes ver que algunos valores esperados para respuestas estándar, como error
, errors
, o url
, están resaltados por TypeScript. Esto se debe a que el linter considera que estos valores podrían ser indefinidos. Esto se resuelve fácilmente con una comprobación adicional junto con el estado, pero ya muestra el problema con este enfoque.
Además, observa que en la línea con console.log(data.user.id)
, el valor user no está resaltado como potencialmente indefinido. Así será si recibimos cualquier tipo de respuesta que no sea un éxito.
Usando tipos de utilidad como ApiResponse
y otros, no tendremos esos problemas.
export type UserProfileResponseV2 = ApiResponse<{
user: UserProfile
}> | ApiRedirect
const newUpdateProfileAPI = async(data: Partial<UserProfile>): Promise<UserProfileResponseV2> => {
return Promise.resolve({} as UserProfileResponseV2)
}
Aquí hay una imagen de cómo TypeScript lint este código:
![](https://codigoencasa.com/content/images/2024/06/1_Wh0v-oq7tqygvMV7xAWaGA-1.webp)
En este caso, todo funciona como se esperaba:
- TypeScript entiende que para los estados correspondientes, habrá campos estándar correspondientes.
- Indica que el valor de usuario podría estar indefinido en todos los tipos de respuesta excepto en la de éxito. Sin embargo, tras comprobar el éxito de la respuesta, este valor no aparece resaltado y está definido.
Conclusión
Tras implementar estos tipos de utilidad en el proyecto, la experiencia del desarrollador mejoró notablemente. Ahora, los tipos se corresponden totalmente con los posibles escenarios de respuesta que puede proporcionar la API.
Esto también ayudará a evitar posibles errores en los que se podrían utilizar algunos valores que no están disponibles en ciertos tipos de respuesta, como en el ejemplo con el valor de usuario.
Además, no hay necesidad de mirar la implementación del procesamiento de respuesta en el código para entender los tipos de respuesta reales. Puedes ver inmediatamente la imagen completa.