El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

Cómo escribir tipos de respuesta API con TypeScript

Organice los tipos de TypeScript para las respuestas de la API con el fin de garantizar una gran experiencia de desarrollo, el mantenimiento del código y evitar errores de codificación obvios.

· 7 min de lectura
Cómo escribir tipos de respuesta API con TypeScript

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
}
Ejemplo de un tipo básico que puede utilizarse para escribir tipos de respuesta

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)
}
Ejemplo de escritura de tipos de respuesta utilizando el tipo básico

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.

CPU
1 vCPU
MEMORIA
1 GB
ALMACENAMIENTO
10 GB
TRANSFERENCIA
1 TB
PRECIO
$ 4 mes
Para obtener el servidor GRATIS debes de escribir el cupon "LEIFER"

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>
Tipos de utilidad para los tipos de respuesta

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 }>
Ejemplo de utilización de los tipos de utilidad de respuesta

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)
    }
}
Cómo podría ser una función que interactúa con una respuesta de la API.

Aquí hay una imagen de cómo TypeScript pelusa este código:

Cómo TypeScript pelusa el código con un tipo de respuesta que utiliza el tipo ApiData

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)
}
Código refactorizado con tipos de utilidad de respuesta

Aquí hay una imagen de cómo TypeScript lint este código:

Cómo TypeScript pelusa el código con el tipo de respuesta refactorizado

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.

Fuente