Desgajando objetos en TypeScript

Un práctica bastante común en el mundillo de JS por lo que ando viendo últimamente, es trabajar con sub-objetos. A veces un objeto dado tiene muchas propiedades públicas y a la hora de trabajar con el de alguna manera solo es necesario usar dos o tres propiedades ignorando el resto.

Hacer esto es trivial en JS debido a su naturaleza dinámica y si definimos, por ejemplo, que un objeto Person tendrá las propiedades id, name, surname y birthday podemos tratar con un subconjunto de dichas propiedades muy facilmente

// Acepto que un objeto Person es aquel que tiene las propiedades mencionadas
const gabriel = {  
  id: 1,
  name: 'Gabriel',
  surname: 'Ferreiro',
  birthday: new Date(1991, 3, 30)
};

const acceptPerson = (person) => {  
  console.log('hola', person.name);
};

Así, la función acceptPerson funcionará con cualquier objeto que tenga una propiedad pública name, como es mi objeto persona, aunque también cualquier otro que satisfaga dicha condición. Así, podría hacerme copias del objeto gabriel que solo contengan las propiedades sobre las que operará la función, para asegurarme de que no modifico la referencia original y no causo efectos colaterales

const copiaGabriel = {};  
copiaGabriel.name = gabriel.name;

acceptPerson(copiaGabriel);  

Moviéndolo a TypeScript

Puedo llevarme este concepto a TypeScript para aprovechar sus ventajas ganando además el tipado estático del lenguaje. En TypeScript puedo aprovechar dicho tipado para definir claramente qué es qué no es un objeto de tipo Person usando una interfaz

interface Person {  
  id: number;
  name: string;
  surname: string;
  birthday: Date;
}

...

const gabriel: Person = {  
  id: 1,
  name: 'Gabriel',
  surname: 'Ferreiro',
  birthday: new Date(1991, 3, 30)
};

const acceptPerson = (person: Person) => {  
  console.log(person.name);
};

Esto me trae un problema: Si le paso a mi función algún objeto que no cumpla la interfaz TypeScript empezará a quejarse con toda la razón del mundo ya que la firma especifica que acceptPerson recibe un Person y no otra cosa. La solución rápida y mala es cambiar el tipo del parámetro person a any y listo, TS se comerá cualquier cosa que le pase, pero para eso no tiene sentido usar TS.

Una salida es valerme de la clase Pick<T, K> que viene en la biblioteca estandar de TS y que sirve para definir un subconjunto de claves K para un tipo T, que es justo lo que quiero. Las claves que acepta Pick se definen como un tipo union, pudiendo tipar mi función así:

const acceptPickPerson = (person: Pick<Person, 'name'|'surname') => {  
  console.log(person.name);
};

De esta manera TS espera que el parámetro person sea un objeto que contenga las claves name y surname y que cumplan el tipado de las mismas dentro del tipo Person ambas las dos. Podría usar esta nueva función tal que así

acceptPickPerson({  
  name: gabriel.name,
  surname: gabriel.surname
});

y TS no se quejaría. Bien.

De todas formas me sigue sobrando la propiedad surname que no quiero para nada en la función acceptPickPerson. Hay una forma de crear un tipo union que contenga todas las propiedades de un tipo dado, usando el keyword keyof de TS.

Con este operador puedo enumerar todas las propiedades de mi interfaz Person para que se incluyan siempre en el parámetro person de mi función

const acceptPickPerson = (person: Pick<Person, keyof Person) => {  
  console.log(person.name);
};

// Esto se puede ver así
// Pick<Person, keyof Person> => Pick<Person, 'id'|'name'|'surname'|'birthday'>

Esto me provoca otro problema, aunque ya no tengo que escribir a mano las propiedades que quiero en mi función (que podrían cambiar y quedarse mal escritas en un refacto, cosa que evito usando keyof) ahora la función vuelve a aceptar solo objetos que cumplan la interfaz Person...

Y aquí viene el chiste: Puedo usar genéricos en la definición de la función, y heredar del union que me da keyof para decirle a TS que lo que quiero es un objeto que contenga cualquiera de las claves definidas por este último, quedándome así:

const acceptPickPerson = <K extends keyof Person>(person: Pick<Person, K>) => {  
  console.log(person.name);
};

Es una manera de decirle a TS que K es un subconjunto de las claves de Person, y usando Pick me aseguro de que además cumplan los tipos que tienen en Person pudiendo, por fin, llamar a la función como al inicio del post:

acceptPersonPick({  
    name: gabriel.name
});

Concluyendo

A veces TypeScript te obliga a dar vueltas (a veces vueltas de más) para conseguir lo que en JavaScript es una cosa trivial y directa, pero si aprecias el sistema de tipos del primero está bien conocer esta clase de truquitos para poder seguir usando esas construcciones de JS sin renunciar a los tipos incluso en este tipo de métodos.

Gabriel Ferreiro

Read more posts by this author.