Patrón Adaptador (Caso de Uso en TypeScript)

Patrón Adaptador (Caso de Uso en TypeScript)

Si hay algo seguro en el desarrollo de software, además de los bugs, es encontrarte con código o datos que no controlamos. Llámalo librerías desactualizadas, APIs externas, o servicios de terceros que parecen diseñados para ponernos a prueba.

Para no perder la cordura, tenemos un superhéroe estructural: el patrón Adaptador. Este nos permite meter una capa intermedia entre nuestro código bonito (o no tan bonito, pero es nuestro) y ese caos externo del que no tenemos control absoluto.

Adapter Pattern

adapter-pattern-uml

Implementar este patrón es cosa sencilla. Me gusta verlo de esta forma: necesitas saber qué es lo que vas a adaptar (el Adaptee), definir claramente cómo quieres integrar eso con tu código (el Target), y con base en eso, diseñar la clase Adapter.

Vamos a verlo con un ejemplo. Imagina que tienes una librería que te devuelve una película random, pero resulta que te manda la data como tuplas: el título de la película y el año.

/**
 * Clase de libreria que no controlamos
 */
class RandomMovie {
  public getMovie(): [string, number] {
    const movies: [string, number][] = [
      ['The Shawshank Redemption', 1994],
      ['The Godfather', 1972], 
      ['The Dark Knight', 2008], 
      ['The Lord of the Rings', 2003], 
      ['Pulp Fiction', 1994],
    ];
    return movies[Math.floor(Math.random() * movies.length)];
  }
}

Nosotros necesitamos trabajar con objetos literales de toda la vida, algo así:

const movie = {
  title: 'The Godfather',
  year: 1972,
};

¡Listo! Ya tenemos un problema perfecto para resolver con un adaptador.

En este caso, nuestra clase Adaptee puede ser directamente RandomMovie, o puedes crear una clase propia que extienda de RandomMovie. Esto depende de qué tan creativo te sientas ese día.

Primero, definamos algunas interfaces para establecer qué necesitamos para construir el adaptador:

interface Movie {
  name: string;
  year: number;
}


interface IRandomMovie {
  getRandomMovie(): Movie;
}

Aquí definimos Movie, que es el tipo de objetos con los que nuestra aplicación va a trabajar, y una interfaz para dejar claro qué funcionalidades debe tener nuestro adaptador.

El adaptador quedaría así:

/**
* El Adaptador hace que la interfaz de Adaptee sea compatible con la interfaz de Target.
*/
class RandomMovieAdapter implements IRandomMovie {
  private adaptee: RandomMovie;


  constructor(adaptee: RandomMovie) {
    this.adaptee = adaptee;
  }


  public getRandomMovie(): Movie {
    const [name, year] = this.adaptee.getMovie();
    return { name, year };
  }
}

De esta forma, en nuestro código ya no usamos directamente la clase de la librería. Ahora trabajamos con ella a través del adaptador, y todo fluye más bonito:

/**
* El código del cliente admite todas las clases que siguen la interfaz de Target.
*/
function clientCode(target: IRandomMovie) {
  console.log(target.getRandomMovie());
}

const adaptee = new RandomMovie();

const target = new RandomMovieAdapter(adaptee);

clientCode(target);   // { name: 'The Lord of the Rings', year: 2003 }
clientCode(target);   // { name: 'Pulp Fiction', year: 1994 }
clientCode(target);   // { name: 'The Dark Knight', year: 2008 }

Caso de uso en aplicaciones Front-End

Ahora que ya definimos el patrón, mi objetivo con este post es mostrar el uso más común que me he encontrado al meterle mano a aplicaciones frontend. Esto es completamente agnóstico de tecnologías: aplica igual para Angular, React, Vue o cualquiera de los 450 frameworks de frontend que seguro saldrán el mes que viene.

En la mayoría de los casos, las aplicaciones frontend consumen información de servicios de terceros o incluso de servicios propios que, para variar, pueden cambiar de un día para otro o devolver datos que no son 100% compatibles con la app.

Supongamos que obtenemos información de usuarios desde una API externa, y al hacer un request GET nos retorna un usuario que luce algo así:

{
  "username": "string",
  "email": "[email protected]",
  "name": {
    "first_name": "string",
    "last_name": "string"
  },
  "address": {
    "street": "string",
    "city": "string",
    "zip_code": 0,
    "coords": {
      "lat": 0,
      "lon": 0
    }
  },
  "phone_number": "string",
  "id": 0,
  "joined_at": "2024-12-04T04:57:23.969Z"
}

Trabajar directamente con los objetos tal como llegan puede traer varios dolores de cabeza:

  • Fechas: Las APIs suelen devolver fechas en formatos incómodos; necesitarás convertirlas constantemente.
  • Convenciones de nombres: Si la API usa snake_case o formatos raros, tu código sufrirá para mantenerse consistente con camelCase.
  • Datos anidados: Las respuestas pueden ser un laberinto de datos difíciles de manejar y propensos a errores si los usas directamente.
  • Datos innecesarios: Las APIs devuelven mucha información irrelevante, desperdiciando recursos si no la filtras.
  • Cambios en la API: Si la API cambia su estructura, sin un adaptador tendrás que modificar el código en muchos lugares.
  • Formatos regionales: Fechas, monedas o números podrían no ajustarse a las preferencias de los usuarios; mejor centralizar las conversiones.

Ahora, en nuestra aplicación web, necesitamos adaptar esos objetos entrantes para que estén validados y formateados según nuestras reglas. Empecemos definiendo las interfaces necesarias:

import { Dayjs } from "dayjs";

export interface UserName {
  firstName: string;
  lastName: string;
}

export interface Coords {
  lat: number;
  lng: number;
}

export interface Address {
  street: string;
  city: string;
  zipCode: number;
  coords: Coords;
}

export interface User {
  id: number;
  username: string;
  email: string;
  name: UserName;
  address: Address | null;
  phoneNumber: string;
  joinedAt: Dayjs;
}

Estas son las interfaces que definen las entidades con las que vamos a trabajar. Cualquier objeto User que usemos en nuestra app tiene que lucir exactamente así, sí o sí.

Sabemos cómo deberían verse los objetos que vienen de la API... en teoría. Pero como mencioné antes, no podemos confiar ciegamente en algo que no controlamos. Aquí es donde entra el adaptador, nuestro escudo protector, para asegurarnos de que todo lo que recibimos sea como esperamos.

Para este caso, vamos a usar Zod y definir unos esquemas que reflejen la estructura esperada de la respuesta de la API:

import { z } from 'zod';

const CoordsSchema = z.object({
  lat: z.number(),
  lon: z.number()
});

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip_code: z.number(),
  coords: CoordsSchema
});

const NameSchema = z.object({
  first_name: z.string(),
  last_name: z.string()
});

export const UserSchema = z.object({
  id: z.number().min(1),
  username: z.string(),
  email: z.string().email(),
  name: NameSchema,
  address: AddressSchema.nullable(),
  phone_number: z.string(),
  joined_at: z.string().datetime()
});

export type CoordsApi = z.infer<typeof CoordsSchema>;
export type UserNameApi = z.infer<typeof NameSchema>;
export type AddressApi = z.infer<typeof AddressSchema>;
export type UserApi = z.infer<typeof UserSchema>;

¡Genial! Estos esquemas validan los objetos de usuario que nos llegan de la API. Puedes agregar tantas validaciones como creas necesarias, porque en este juego, entre más validado el dato, menos lloramos después. Además, exportamos los tipos generados por los esquemas para reutilizarlos más adelante.

Con eso listo, creamos nuestra clase adapter. Primero definimos la interfaz que deberá cumplir:

/**
 * An adapter interface that adapts API objects to model objects
 * @template T - The API object type
 * @template R - The model object type
 */
interface Adapter<T, R> {
  /**
   * Adapts the given API object to the model object
   * @param api - The API object to adapt from
   */
  adaptFromApi(api: T): R;
}

Ahora sí, implementemos la clase para adaptar la entidad User:

import {
  AddressApi,
  UserApi,
  UserNameApi,
  UserSchema,
} from "../../api/models/user-model";
import { Address, User, UserName } from "../../models/user-model";
import dayjs from "dayjs";
import { z } from "zod";


/**
 * An adapter class that adapts User API objects to User model objects
 */
class UserAdapter implements Adapter<UserApi, User> {

  /**
   * Maps the name from the API to the model
   * @param nameApi Object with first_name and last_name properties
   * @returns Object with firstName and lastName properties
   */
  private adaptNameFromApi(nameApi: UserNameApi): UserName {
    return {
      firstName: nameApi.first_name,
      lastName: nameApi.last_name,
    };
  }

  /**
   * Maps the address from the API to the model
   * @param address Object with street, city, zip_code, and coords properties
   * @returns Object with street, city, zipCode, and coords properties
   */
  private adaptAddressFromApi(address?: AddressApi | null): Address | null {
    if (!address) {
      return null;
    }

    return {
      street: address.street,
      city: address.city,
      zipCode: address.zip_code,
      coords: {
        lat: address.coords.lat,
        lng: address.coords.lon,
      },
    };
  }

  adaptFromApi(api: UserApi): User {
    if (typeof api !== "object" || api === null) {
      throw new Error("Invalid input");
    }
    try {
      const userValid = UserSchema.parse(api);
      return {
        id: userValid.id,
        username: userValid.username,
        email: userValid.email,
        name: this.adaptNameFromApi(userValid.name),
        address: this.adaptAddressFromApi(userValid.address),
        phoneNumber: userValid.phone_number,
        joinedAt: dayjs(userValid.joined_at),
      };
    } catch (error) {
      console.log(error);
      if (error instanceof z.ZodError) {
        throw new Error("Error al parsear el usuario");
      }
      throw error;
    }
  }
}

export default UserAdapter;

El método adaptFromApi() hace el trabajo sucio: valida la entrada, la convierte y nos regresa un objeto User limpio, validado y listo para usarse, como un MVP pero en formato JSON.

El último paso es usar este adaptador en tu aplicación. Mi recomendación siempre es manejar las peticiones HTTP en una capa de servicios. Aquí un ejemplo:

/**
 * A service class for user related operations
 */
class UserService {

  /**
   * Method to get a user by ID
   * @param id User ID
   * @returns Object with user details
   */
  public static async getUserById(id: number): Promise<User> {
    try {

      const response = await fakeApi.get<UserApi>(`/users/${id}`);

      const userAdapter = new UserAdapter();   // NUESTRO ADAPTER!!!

      return userAdapter.adaptFromApi(response.data);
      
    } catch (error) {
      if (error instanceof AxiosError) {
        throw new Error(error.response?.data.detail || error.message);
      } else if (error instanceof Error) {
        throw new Error(error.message);
      }
      throw new Error("Failed to get user");
    }
  }
  
}

export default UserService;

En este caso, asumo que la API devuelve un objeto de tipo UserApi, por lo que lo específico en el genérico del método get(). Pero, como siempre, puede ser cualquier cosa, desde un objeto válido hasta un desastre digno de llorar.

Cuando tengas la respuesta, pásala directamente al adaptador. Él hará su magia y, si algo falla, puedes lanzar excepciones detalladas desde el adaptador para que tu servicio (en este caso, UserService) las maneje como mejor te convenga.

Así, en tus componentes, sin importar el framework o librería que uses, puedes trabajar con objetos seguros y correctamente tipados gracias a nuestro héroe, TypeScript. ¡Usa TypeScript! Porque nadie debería sufrir bugs por falta de tipos.

import UserService from "./services/user-service";

const ViewUser = () => {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  const fetchUser = async () => {
    setLoading(true);
    setError(null);
    try {
      const user = await UserService.getUserById(123);
      setUser(user);
    } catch (err) {
      setError((err as Error).message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={fetchUser}>Fetch User</button>

      {loading && <p>Loading...</p>}
      {error && <p>{error}</p>}
      {user && (
        <div>
          <h2>{user.name.firstName} {user.name.lastName}</h2>
          <p>{user.email}</p>
          <p>{user.phoneNumber}</p>
          <p>{user.joinedAt.format("YYYY-MM-DD")}</p>
          {user.address && (
            <div>
              <p>{user.address.street}</p>
              <p>{user.address.city}</p>
              <p>{user.address.zipCode}</p>
              <p>{user.address.coords.lat}, {user.address.coords.lng}</p>
            </div>
          )}
        </div>
      )}
    </div>
  );
};

export default ViewUser;

Conclusión

El patrón Adaptador no solo es útil, es prácticamente indispensable cuando trabajamos con datos o servicios que no controlamos. Nos permite mantener nuestro código limpio, seguro y adaptable a cambios futuros, evitando convertir nuestras aplicaciones en un caos.

En el caso de aplicaciones frontend, usar adaptadores para manejar datos de APIs externas puede marcar la diferencia entre un proyecto sostenible y uno lleno de dolores de cabeza. Validar, transformar y controlar los datos en un solo punto central nos ahorra problemas de compatibilidad, errores de formato y horas de debugging.

¿La lección de todo esto? Si algo no está bajo tu control, usa un adaptador. Y por favor, usa TypeScript. El futuro tú te lo agradecerá cuando no tenga que descifrar un error causado por un undefined.