Creo que alguna vez te has enfrentado a una desincronización de tipos imperceptible entre la API y tu modelo que te provoca un error cuando menos te lo esperas. Podría ser un valor indefinido en una propiedad obligatoria en la respuesta de la API sin ningún error.

También estoy bastante seguro de que estabas molesto debido a los modelos complejos y no optimizados procedentes de la API.

Permíteme presentarte el enfoque que minimiza esos riesgos e inconvenientes y te da un control total sobre el tipo de respuesta de la API. Además, te permitirá mapear de forma segura ese tipo en tu interfaz optimizada. Además, voy a introducir la librería zod.js y mostrarte el caso de uso real del análisis de esquemas y la inferencia de tipos.

Me gustaría presentar la implementación del servicio angular que se encarga de obtener los datos de la API, analizar la respuesta de acuerdo con un esquema dado y mapearlo en una interfaz optimizada que se puede utilizar fácilmente en la plantilla o la lógica de negocio de su aplicación. Además, algunos errores potenciales serán detectados y gestionados lo antes posible.

Si tu quieres aprender acerca de Angular recuerda que tenemos 🎉 WORKSHOP

Servicio de datos


A continuación puedes encontrar el ejemplo de servicio angular que es responsable de obtener y devolver la respuesta en la forma esperada. Deliberadamente el nombre contiene una frase "datos" para indicar su única responsabilidad.

@Injectable({
  providedIn: 'root',
})
export class UsersDataService {
  httpClient = inject(HttpClient);

  fetchUsers(): Observable<User[]> {
    const url = 'https://dummyjson.com/users';
    return this.httpClient.get(url).pipe(
      map((response) => {
        const dto = parseDTO(response);
        if (dto.success) {
          return fromDTO(dto.data);
        } else {
          console.error(dto.error);
          return [];
        }
      }),
      catchError((error) => {
        console.error(error);
        return of([]);
      })
    );
  }
}

Cuando recibe una respuesta, intenta parsearla. En caso de éxito, lo que significa que todas las propiedades requeridas están en su lugar y tienen los tipos esperados, se puede mapear en un modelo optimizado para la aplicación. La respuesta analizada se llama "dto" que significa "objeto de transferencia de datos", es nuestro contrato con el backend.

En caso de error, que ocurre cuando la respuesta no cumple todas las condiciones obligatorias (simplemente es diferente de lo que esperabas), puedes manejarlo como necesites. En el ejemplo anterior el servicio devuelve un array vacío pero puede devolver cualquier valor como undefined o algún mensaje de error. Puede ser lo que sea necesario para manejar adecuadamente el error.

Objeto de transferencia de dato


Ahora echemos un vistazo al fichero users.dto.ts que contiene la parte central de esta arquitectura que son el esquema, las funciones parse y el tipo inferido.

import { z } from 'zod';

const usersSchema = z.object({
  users: z.array(
    z.object({
      id: z.number(),
      firstName: z.string(),
      lastName: z.string(),
      age: z.number().optional(),
      gender: z.string(),
      address: z.object({
        address: z.string(),
        city: z.string(),
        state: z.string(),
      }),
      company: z.object({
        address: z.object({
          address: z.string(),
          city: z.string().optional(),
          state: z.string(),
        }),
        name: z.string(),
      }),
    })
  ),
});

export type UsersDto = z.infer<typeof usersSchema>;

export function parseDTO(source: unknown) {
  return usersSchema.safeParse(source);
}

En primer lugar, vamos a mencionar la biblioteca zod.js que se ha utilizado aquí. ¿Qué es zod.js? Según su documentación, es una biblioteca de validación y declaración de esquemas de TypeScript. En resumen, ofrece al desarrollador tres características que cambian el juego:

  • La posibilidad de definir esquemas: Soporta un montón de opciones como primitivas, objetos anidados complejos, opcionales/nulables, uniones discriminadas y mucho más. En realidad te permite describir cada objeto json posible y en este caso la respuesta api.
  • Inferencia de tipo: Puede extraer el tipo TypeScript de un esquema dado por lo que no hay necesidad de crear un tipo dto manualmente. Zod.js lo hará por nosotros.
  • Análisis de esquemas: Validará un objeto dado con respecto a todas las condiciones del esquema y lo mapeará en un tipo dto en caso de éxito, de lo contrario devolverá un error legible por humanos con los detalles de lo que está mal en la fuente.


En el ejemplo dado userSchema refleja la respuesta de la api ficticia "https://dummyjson.com/users" que, como probablemente hayas notado, contiene muchas propiedades y no todas son necesarias en nuestra aplicación. Por lo tanto, sólo las necesarias se incluyen en el esquema.

Se utilizan todos los tipos relevantes como string, object y array. Si necesitas describir esquemas más complejos, deberías consultar la documentación de zod.js (https://github.com/colinhacks/zod).

A continuación, se utiliza la función infer de zod para crear un tipo a partir del esquema. Para comprobar la potencia de esta función puedes pasar el ratón por encima del tipo UserDto y te darás cuenta de que infiere correctamente todas las propiedades, incluso las opcionales. Eso es un verdadero cambio de juego.

Vale la pena mencionar que el modo estricto en tsconfig.json es necesario. Sin él, typescript inferiría cada propiedad como opcional. Realmente te animo a habilitar siempre esta opción en tu proyecto que te permite utilizar toda la potencia de TypeScript.

Mapeador


Finalmente, vamos a describir la última pero no menos importante parte de la arquitectura dada, que es user.mapper.ts que es responsable de mapear desde DTO a una interfaz optimizada para la aplicación.

Por "optimizada" me refiero al objeto que puede ser fácilmente mostrado en la plantilla o procesado en la lógica de negocio de la aplicación. Debe ser hecho a medida y simple. Por lo tanto, debe ser lo más plana posible y contener nombres auto explicativos de las propiedades con los tipos pertinentes.

En este ejemplo, la interfaz de usuario tiene este aspecto:

export interface User {
  id: number;
  fullName: string;
  age?: number;
  gender: string;
  company: {
    name: string;
    address: string;
  };
  address: string;
}

Mapper es una función pura que toma UsersDto y devuelve un array de User. Dado que la respuesta se ha analizado correctamente, no es necesario comprobar la presencia o el tipo de una propiedad en particular. Zod.js garantiza al 100% que todo cumple con la definición del esquema.

export function fromDTO(dto: UsersDto): User[] {
  return dto.users.map((user) => {
    const companyAddress = user.company.address;
    const userAddress = user.address;
    const fullName = `${user.firstName} ${user.lastName}`;
    return {
      id: user.id,
      fullName,
      age: user.age,
      gender: user.gender,
      company: {
        name: user.company.name,
        address: join(
          [companyAddress.address, companyAddress.city, companyAddress.state],
          ', '
        ),
      },
      address: join(
        [userAddress.address, userAddress.city, userAddress.state],
        ', '
      ),
    };
  });
}

Ahora la lista de usuarios puede utilizarse fácilmente en el componente. La respuesta se ha analizado correctamente y se ha asignado a un modelo optimizado.

Espera, ¿es realmente necesario?


Supongo que te preguntarás por qué no he añadido un tipo genérico al método get del servicio httpClient. A primera vista, devolvería la respuesta en un tipo esperado por lo que todo este lío con el análisis del esquema no es necesario bueno, nada podría estar más equivocado. No tienes ninguna garantía de que el tipo real sea el mismo que tu tipo. Permítanme demostrarlo.

Vamos a modificar nuestro servicio para utilizar algún tipo genérico.

@Injectable({
  providedIn: 'root',
})
export class UsersDataService {
  httpClient = inject(HttpClient);

  fetchUsers(): Observable<User[]> {
    const url = 'https://dummyjson.com/users';
    return this.httpClient.get<User[]>(url).pipe(
      tap((users) => console.log(users)),
      catchError((error) => {
        console.error(error);
        return of([]);
      })
    );
  }
}

Aunque sabemos que la api devuelve un modelo diferente, no se muestra ningún error. El compilador de Angular da por hecho que la respuesta es un array de User. Sorprendentemente, al ejecutar el código se podría ver un tipo completamente diferente al esperado.

Obviamente resultará en algún error o bug en una parte muy inesperada de la aplicación. Es por eso que la respuesta debe ser analizada y los errores potenciales deben ser manejados tan pronto como sea posible. Con el uso de la arquitectura presentada en este artículo, esto ocurre justo después de recibir los datos del backend.

Conclusión


Teniendo estas tres partes (dto, service y mapper) puedes utilizar fácilmente el servicio de datos en tu componente para obtener datos y obtenerlos en la forma esperada. Como puedes ver, este enfoque resuelve cualquier problema relacionado con la respuesta api que pueda ocurrir y llevará tu arquitectura al siguiente nivel con las siguientes mejoras es una protección en caso de desincronización inesperada del contrato entre la api y la app, la respuesta se valida para que estés seguro de que todas las propiedades requeridas están en su lugar y tienen tipos relevantes, el modelo se optimiza a las necesidades de la app, todos los nombres son comprensibles y el objeto se puede visualizar fácilmente

Repositorio

Fuente