Table of Contents
Una de las interesantes charlas a las que pude asistir de la lambda.world vino por parte de FlavioCorpa sobre Lenses ( Functional JavaScript: lenses). En ella se hablaba de Lenses de una manera práctica, es más, empieza con una pequeña implementación propia (no apta para producción) y luego habla de diferentes librerías como Ramda o Monocle-TS.
Functional JavaScript: lenses
La charla empezó con una definición fácil de entender para los que somos más familiares con la programación procedural/imperativa: “Lenses are basically functional getters and setters”. Básicamente lo que conseguimos con Lenses es reutilizar el acceso a datos de una estructura de forma componible e inmutable, ya sea para obtener el dato o para modificarlo; más adelante veremos ejemplos con los que entender mejor el funcionamiento.
Vamos a empezar con una pequeñísima implementación de una Lens:
const Lens = (getter, setter) => {get: getter, set: setter};
Los que no hayáis visto o no conozcáis las Lenses, cuando veáis esto os estaréis preguntando: “y esto puede generar una charla de 1 hora?”. Tenemos que ser conscientes que es un concepto de la programación funcional, por lo que la composición y la inmutabilidad son conceptos muy importantes a tener en cuenta cuando usemos Lenses.
Siguiendo con nuestra implementación, vamos a añadirle a añadirle tipos:
type LensGetter<S,A> = (whole: S) => A;
type LensSetter<S,A> = (whole: S) => (part: A) => S;
interface Lens<S,A> {
get: LensGetter<S,A>;
set: LensSetter<S,A>;
}
const Lens = <S,A>(getter: LensGetter<S, A>, setter: LensSetter<S,A>) => ({get: getter, set: setter});
Ahora vemos como el getter es una simple función que recibe un objeto (whole) y devuelve un trozo del mismo (part). Con el setter lo que buscamos es generar un nuevo whole con el nueva part que le pasamos. Es básicamente las funciones get/set a las que estamos mal acostumbrados, verdad? Sigamos creando nuestras implementaciones de Getter/Setter y viendo su uso:
interface User {
name: String;
company: String;
}
const user: User = {name: "Oscar", company: "Apiumhub"};
const getName = (whole: User): String => whole.name;
const setName = (whole: User) => (part: String): User => ({...whole, name: part});
const nameLens = Lens<User, String>(getName, setName);
expect(nameLens.get(user)).toBe("Oscar");
expect(nameLens.set(user)("Joaquin")).toEqual({name: "Joaquin", company: "Apiumhub"});
Como vemos en nuestro test, el get de nuestra lens pasándole un User nos devuelve su nombre y usando el set de nuestra lens pasándole un nombre nuevo, nos devuelve el objeto completo con el nombre cambiado.
Aquí uno puede pensar que, dada que la implementación la estamos picando nosotros, el get puede apuntar a una cosa y el set modificar otra muy diferente por lo cual, no tendría mucho sentido pero la teoría no ha dejado esto suelto. Como todo en esta vida (y más en el mundo de las matemáticas/programación), existen leyes. Leyes que se tienen que cumplir para poder asegurar el correcto funcionamiento de, en este caso, una Lens.
Lens Laws
Las leyes de las Lenses son 3 y son fáciles de entender, voy a tratar de explicarlas de una manera más simple para que las podamos entender, hay mucha literatura al respecto que la podéis encontrar en la parte final del artículo:
1.(set after get) Si actualizo con lo que recibo, el objeto no cambia. (Identity)
expect(nameLens.set(user)(nameLens.get(user))).toEqual(user);
Si se cumple esta ley, ya vemos que el set y get deben hacer foco a la misma parte del objeto, de otra manera, no se cumpliría.
2.(get after set) Si actualizo y luego recibo, debo recibir lo que he actualizado.
expect(nameLens.get(nameLens.set(user)("newName"))).toEqual("newName");
Lo primero que se va a ejecutar es el set de nuestra lens, que devolverá un nuevo usuario con un nuevo nombre. Si hacemos el get de ese nuevo usuario, deberíamos recibir el nuevo nombre.
3. (set after set) Si actualizo dos veces, obtengo el objeto actualizado por última vez.
expect(nameLens.set(nameLens.set(user)("newName"))("theNewName")).toEqual(nameLens.set(user)("theNewName"));
Fijaros en el orden, primero se ejecuta lo de dentro, el set del usuario con “newName”. Con ese objeto que me devuelve, lo vuelvo a cambiar pero esta vez a “theNewName”. Este último es el que obtendremos, el expect así lo refleja.
View, Set y Over
Ahora vamos a implementar tres funciones nuevas llamadas: view, set y `over. Estas funciones serán muy sencillas pero nos ayudarán a trabajar con Lenses:
type LensView<S,A> = (lens: Lens<S, A>, whole: S) => A;
type LensSet<S,A> = (lens: Lens<S, A>, whole: S, part: A) => S;
type LensOver<S,A> = (lens: Lens<S, A>, map: Mapper<A, A>, whole: S) => S;
Como se puede observar, los tres tipos son bastante sencillos y nos va a ayudar a trabajar con las lens de una manera mucho más sencilla.
Las implementaciones, como podéis imaginar, son bastante sencillas. Simplemente es llamar a las funciones de la lens con los datos que tocan:
const getNameOfUser: LensView<User, String> = (lens: Lens<User, String>, user: User) => lens.get(user);
const setNameOfUser: LensSet<User, String> = (lens: Lens<User, String>, user: User, newName: String) =>
lens.set(user)(newName);
const overNameOfUser: LensOver<User, String> =
(lens: Lens<User, String>, map: Mapper<String, String>, user: User) =>
lens.set(user)(map(lens.get(user)))
expect(getNameOfUser(nameLens, user)).toEqual("Oscar");
expect(setNameOfUser(nameLens, user, "Joaquin")).toEqual({name: "Joaquin", company: "Apiumhub"});
expect(overNameOfUser(nameLens, (i: String) => i.toUpperCase(), user))
.toEqual({name: "OSCAR", company: "Apiumhub"});
Hasta aquí hemos estado trasteando con entidades muy concretas, vamos a abstraernos de esos tipos tan concretos a empezar a usar genéricos:
const view = <S, A>(lens: Lens<S, A>, obj: S) => lens.get(obj);
const set = <S, A>(lens: Lens<S, A>, obj: S, part: A) => lens.set(obj)(part);
const over = <S,A>(lens: Lens<S, A>, map: Mapper<A, A>, obj: S) => lens.set(obj)(map(lens.get(obj)));
Cambiando User y String por genéricos como S y A, ya tenemos 3 funciones que aplican en todos los contextos, a gusto del consumidor y no se ha roto ningún test, solo hemos tenido que refactorizar el nombre de la función.
Ahora vamos a generalizar la parte de la creación de la Lens junto con sus getters y setters en particular:
const prop = <S, A>(key: keyof S) => (whole: S): A => whole[key];
const assoc = <S, A>(key: keyof S) => (whole: S) => (part: A) => ({...whole, [key]: part});
const lensProp = <S, A>(key: keyof S) => Lens<S,A>(prop(key), assoc(key));
const nameLens = lensProp("name");
En nuestra función prop, como parámetro estamos pasando un valor del tipo: keyof S. Este tipo es un union type de todas las propiedades públicas del objeto S. En el ejemplo siguiente, fallará al compilar si intentaramos asignar a userProps algo que no sea name o age:
interface User { name: string; age: number } let userProps: keyof User; // 'name' | 'age'
Como vemos, con una única llamada a una función indicando la parte de la estructura a la que queremos hacer foco nos bastaría para tener todo lo que hemos explicado en este artículo por ahora, todo normal todo básico.
Composición
Como último y más importante, vamos a trabajar con la composición de Lenses. Con la composición de Lenses conseguiremos llegar a los datos más profundos dentro de nuestra estructura de una manera más simple.
Lo primero que haremos es crearnos una función que dada dos Lenses, nos devuelva una Lens compuesta. A nivel de tipos podríamos decir, tengo una Lens que habla de tipo A como una estructura de datos y B como una parte de A y también tengo otra Lens que, conoce B como una estructura de datos y C como una parte interna de ella. Nuestra función debe devolvernos una Lens que conozca A como una estructura y nos deje trabajar con una parte de nivel 2 de tipo C:
const compose = <A, B, C>(lens1: Lens<B, C>, lens2: Lens<A, B>): Lens<A, C> => ({
get: (whole: A) => lens1.get(lens2.get(whole)),
set: (whole: A) => (part: C) => lens2.set(whole)(lens1.set(lens2.get(whole))(part))
});
El código es simple y solamente mirando la firma del método podríamos entender que hace exactamente. A partir de aquí vamos a empezar a usar una estructura de datos un poco más compleja:
interface Company {
name: string;
location: string;
}
interface Contact {
name: string;
company: Company;
}
const contact: Contact = {
name: "Oscar",
company: {
name: "Apiumhub",
location: "Barcelona"
}
};
Como primer paso, lo que vamos hacer es acceder, componiendo Lenses, al nombre de la compañía de nuestro contacto. Primero debemos crear dos Lens, una que haga foco en la parte Company de nuestro Contact, y la siguiente que haga foco al nombre de la compañía (string) de un Company:
const companyLens = lensProp<Contact, Company>("company");
const companyNameLens = lensProp<Company, string>("name");
const contactCompanyNameLens: Lens<Contact, string> = compose(companyNameLens, companyLens);
const locationLens = lensProp<Company, Location>("location");
const cityNameLens = lensProp<Location, string>("city");
const companyLocationLens: Lens<Contact, Location> = compose(locationLens, companyLens);
const locationCityNameLens: Lens<Contact, string> = compose(cityNameLens, companyLocationLens);
it('focus nested data', () => {
expect(view(contactCompanyNameLens, contact)).toEqual("Apiumhub");
expect(over(contactCompanyNameLens, toUpperCase, contact).company.name).toEqual("APIUMHUB")
});
it('composing composed lens', () => {
expect(view(locationCityNameLens, contact)).toEqual("Barcelona");
expect(over(locationCityNameLens, toUpperCase, contact).company.location.city).toEqual("BARCELONA")
});
Genial! Ya tenemos la capacidad de crear Lenses, componerlas y trabajar con ellas. Dicho esto, todo el código que existe en este artículo, pese a funcionar, no es recomendable usar en producción, nosotros en Apiumhub usamos mucho la librería Ramda aunque existen otras muchas buenas como Monocle-ts (Ya comentadas al inicio del artículo).
Donde usar una Lens
Para terminar, vamos ha hablar de cuándo usar Lenses y cuando NO usarlas.
He leído muchos artículos y presentaciones donde hablan de cómo usarlas en dominio pero es algo que hay que pensarlo bien.
El caso de uso para Lens en dominio es para crear getters y setters, por lo que entramos en temas de un mal diseño de nuestro dominio. Estamos rompiendo la encapsulación.
Me atrevería a decir que usar Lens en dominio es un anti-pattern pese a que no lo he llegado a leer en ningún otro sitio ya que la mayoría de explicaciones y charlas que ves son MUY técnicas, no se habla de negocio en ningún momento por lo que a mi ya me da que pensar. En ciertos escenarios que te plantean dónde usar Lens en dominio y lo que se ve son God Objects que obviamente necesitas ayuda de todos los tecnicismos posibles para poder solventar una mala decisión de diseño.
Por otra parte, veo muy interesante el uso de Lens en capas fronteras a nuestro dominio, todo DTO de entrada ya sea por HTTP, base de datos, eventos, etc. La solución es muy elegante y simple de entender, fácil de testear (e innecesario si me apuras, ya que Lens está fuertemente tipado).
Conclusion: functional JavaScript
Como ya he comentado más arriba, hay mucha literatura al respecto en la que me he basado y aquí os la dejo, os informo de ya que como os pongáis a profundizar en el tema, vais a entrar en una espiral de programación funcional, matemáticas en general y teoría de categorías en concreto (aunque abstracto) que genera adicción:
Bibliografía:
- Lenses, Stores and Yoneda
- Category Theory: Lens by Bartosz
- Functional Lenses
- Lenses from scratch
- Lenses Are Exactly the Coalgebras for the Store Comonad
- Lenses, Folds, and Traversals
Suscríbete a nuestro newsletter para estar al día de functional JavaScript en concreto!
Si este artículo sobre functional JavaScript te gustó, te puede interesar:
Simular respuestas del servidor con Nodejs
Principio de responsabilidad única
Arquitectura de microservicios
F-bound en Scala: traits genéricos con higher-kinded types
Scala Generics I : Clases genéricas y Type bounds
Author
-
Software developer with over 16 years experience working as Fullstack Developer & Backend Developer.
Ver todas las entradas
More to Explore