Cierres, funciones curry y abstracciones geniales en JavaScript

En este artículo, hablaremos sobre cierres y funciones de curry y jugaremos con estos conceptos para construir abstracciones geniales. Quiero mostrar la idea detrás de cada concepto, pero también hacerlo muy práctico con ejemplos y código refactorizado para hacerlo más divertido.

Cierres

Los cierres son un tema común en JavaScript, y es con el que comenzaremos. Según MDN:

Un cierre es la combinación de una función agrupada (encerrada) con referencias a su estado circundante (el entorno léxico).

Básicamente, cada vez que se crea una función, también se crea un cierre y da acceso al estado (variables, constantes, funciones, etc.). El estado circundante se conoce como lexical environment.

Muestremos un ejemplo simple:

function makeFunction() { const name = 'TK'; function displayName() { console.log(name); } return displayName; }; 

¿Qué tenemos aquí?

  • Nuestra función principal se llama makeFunction
  • Una constante nombrada namese asigna con la cadena,'TK'
  • La definición de la displayNamefunción (que solo registra la nameconstante)
  • Y finalmente, makeFunctiondevuelve la displayNamefunción

Esta es solo una definición de función. Cuando llamamos makeFunction, creará todo lo que contenga: una constante y otra función, en este caso.

Como sabemos, cuando displayNamese crea la función, también se crea el cierre y hace que la función sea consciente de su entorno, en este caso, la nameconstante. Es por eso que podemos mantener console.logla nameconstante sin romper nada. La función conoce el entorno léxico.

const myFunction = makeFunction(); myFunction(); // TK 

¡Excelente! Funciona como se esperaba. El valor de retorno de makeFunctiones una función que almacenamos en la myFunctionconstante. Cuando llamamos myFunction, se muestra TK.

También podemos hacer que funcione como una función de flecha:

const makeFunction = () => { const name = 'TK'; return () => console.log(name); }; 

Pero, ¿y si queremos pasar el nombre y mostrarlo? ¡Simple! Utilice un parámetro:

const makeFunction = (name = 'TK') => { return () => console.log(name); }; // Or as a one-liner const makeFunction = (name = 'TK') => () => console.log(name); 

Ahora podemos jugar con el nombre:

const myFunction = makeFunction(); myFunction(); // TK const myFunction = makeFunction('Dan'); myFunction(); // Dan 

myFunction es consciente del argumento que se pasa y si es un valor predeterminado o dinámico.

El cierre asegura que la función creada no solo sea consciente de las constantes / variables, sino también de otras funciones dentro de la función.

Entonces esto también funciona:

const makeFunction = (name = 'TK') => { const display = () => console.log(name); return () => display(); }; const myFunction = makeFunction(); myFunction(); // TK 

La función devuelta conoce la displayfunción y puede llamarla.

Una técnica poderosa es usar cierres para construir funciones y variables "privadas".

Hace meses estaba aprendiendo estructuras de datos (¡de nuevo!) Y quería implementar cada una. Pero siempre estaba usando el enfoque orientado a objetos. Como entusiasta de la programación funcional, quería construir todas las estructuras de datos siguiendo los principios de FP (funciones puras, inmutabilidad, transparencia referencial, etc.).

La primera estructura de datos que estaba aprendiendo fue la pila. Es bastante simple. La API principal es:

  • push: agrega un elemento al primer lugar de la pila
  • pop: elimina el primer elemento de la pila
  • peek: obtiene el primer elemento de la pila
  • isEmpty: verifica si la pila está vacía
  • size: obtiene la cantidad de elementos que tiene la pila

Podríamos crear claramente una función simple para cada "método" y pasarle los datos de la pila. Luego podría usar / transformar los datos y devolverlos.

Pero también podemos crear una pila con datos privados y solo exponer los métodos API. ¡Hagámoslo!

const buildStack = () => { let items = []; const push = (item) => items = [item, ...items]; const pop = () => items = items.slice(1); const peek = () => items[0]; const isEmpty = () => !items.length; const size = () => items.length; return { push, pop, peek, isEmpty, size, }; }; 

Debido a que creamos la itemspila dentro de nuestra buildStackfunción, es "privada". Solo se puede acceder dentro de la función. En este caso, sólo push, popy lo que se podría tocar los datos. Esto es exactamente lo que estamos buscando.

¿Y cómo lo usamos? Me gusta esto:

const stack = buildStack(); stack.isEmpty(); // true stack.push(1); // [1] stack.push(2); // [2, 1] stack.push(3); // [3, 2, 1] stack.push(4); // [4, 3, 2, 1] stack.push(5); // [5, 4, 3, 2, 1] stack.peek(); // 5 stack.size(); // 5 stack.isEmpty(); // false stack.pop(); // [4, 3, 2, 1] stack.pop(); // [3, 2, 1] stack.pop(); // [2, 1] stack.pop(); // [1] stack.isEmpty(); // false stack.peek(); // 1 stack.pop(); // [] stack.isEmpty(); // true stack.size(); // 0 

Entonces, cuando se crea la pila, todas las funciones son conscientes de los itemsdatos. Pero fuera de la función, no podemos acceder a estos datos. Es privado. Simplemente modificamos los datos utilizando la API incorporada de la pila.

Curry

"Currying es el proceso de tomar una función con múltiples argumentos y convertirla en una secuencia de funciones, cada una con un solo argumento".

- Entrevista frontend

Así que imagine que tiene una función con varios argumentos: f(a, b, c). Usando curry, logramos una función f(a)que devuelve una función g(b)que devuelve una función h(c).

Básicamente: f(a, b, c)->f(a) => g(b) => h(c)

Construyamos un ejemplo simple que suma dos números. Pero primero, sin curry:

const add = (x, y) => x + y; add(1, 2); // 3 

¡Excelente! ¡Súper simple! Aquí tenemos una función con dos argumentos. Para transformarla en una función curry necesitamos una función que reciba xy devuelva una función que reciba yy devuelva la suma de ambos valores.

const add = (x) => { function addY(y) { return x + y; } return addY; }; 

Podemos refactorizar addYen una función de flecha anónima:

const add = (x) => { return (y) => { return x + y; } }; 

O simplifíquelo construyendo funciones de flecha de una línea:

const add = (x) => (y) => x + y; 

Estas tres funciones de curry diferentes tienen el mismo comportamiento: construye una secuencia de funciones con un solo argumento.

¿Cómo podemos usarlo?

add(10)(20); // 30 

Al principio, puede parecer un poco extraño, pero hay una lógica detrás. add(10)devuelve una función. Y llamamos a esta función con el 20valor.

Esto es lo mismo que:

const addTen = add(10); addTen(20); // 30 

Y esto es interesante. Podemos generar funciones especializadas llamando a la primera función. Imagina que queremos una incrementfunción. Podemos generarlo a partir de nuestra addfunción pasando 1como valor.

const increment = add(1); increment(9); // 10 

Cuando estaba implementando Lazy Cypress, una biblioteca npm para registrar el comportamiento del usuario en una página de formulario y generar código de prueba Cypress, quería construir una función para generar esta cadena input[data-testid="123"]. Entonces tenía el elemento ( input), el atributo ( data-testid) y el valor ( 123). Interpolación de esta cadena en JavaScript se vería así: ${element}[${attribute}="${value}"].

My first implementation was to receive these three values as parameters and return the interpolated string above:

const buildSelector = (element, attribute, value) => `${element}[${attribute}="${value}"]`; buildSelector('input', 'data-testid', 123); // input[data-testid="123"] 

And it was great. I achieved what I was looking for.

But at the same time, I wanted to build a more idiomatic function. Something where I could write "Get element X with attribute Y and value Z". So if we break this phrase into three steps:

  • "get an element X": get(x)
  • "with attribute Y": withAttribute(y)
  • "and value Z": andValue(z)

We can transform buildSelector(x, y, z) into get(x)withAttribute(y)andValue(z) by using the currying concept.

const get = (element) => { return { withAttribute: (attribute) => { return { andValue: (value) => `${element}[${attribute}="${value}"]`, } } }; }; 

Here we use a different idea: returning an object with function as key-value. Then we can achieve this syntax: get(x).withAttribute(y).andValue(z).

And for each returned object, we have the next function and argument.

Refactoring time! Remove the return statements:

const get = (element) => ({ withAttribute: (attribute) => ({ andValue: (value) => `${element}[${attribute}="${value}"]`, }), }); 

I think it looks prettier. And here's how we use it:

const selector = get('input') .withAttribute('data-testid') .andValue(123); selector; // input[data-testid="123"] 

The andValue function knows about the element and attribute values because it is aware of the lexical environment like with closures that we talked about before.

We can also implement functions using "partial currying" by separating the first argument from the rest for example.

After doing web development for a long time, I am really familiar with the event listener Web API. Here's how to use it:

const log = () => console.log('clicked'); button.addEventListener('click', log); 

I wanted to create an abstraction to build specialized event listeners and use them by passing the element and a callback handler.

const buildEventListener = (event) => (element, handler) => element.addEventListener(event, handler); 

This way I can create different specialized event listeners and use them as functions.

const onClick = buildEventListener('click'); onClick(button, log); const onHover = buildEventListener('hover'); onHover(link, log); 

With all these concepts, I could create an SQL query using JavaScript syntax. I wanted to query JSON data like this:

const json = { "users": [ { "id": 1, "name": "TK", "age": 25, "email": "[email protected]" }, { "id": 2, "name": "Kaio", "age": 11, "email": "[email protected]" }, { "id": 3, "name": "Daniel", "age": 28, "email": "[email protected]" } ] } 

So I built a simple engine to handle this implementation:

const startEngine = (json) => (attributes) => ({ from: from(json, attributes) }); const buildAttributes = (node) => (acc, attribute) => ({ ...acc, [attribute]: node[attribute] }); const executeQuery = (attributes, attribute, value) => (resultList, node) => node[attribute] === value ? [...resultList, attributes.reduce(buildAttributes(node), {})] : resultList; const where = (json, attributes) => (attribute, value) => json .reduce(executeQuery(attributes, attribute, value), []); const from = (json, attributes) => (node) => ({ where: where(json[node], attributes) }); 

With this implementation, we can start the engine with the JSON data:

const select = startEngine(json); 

And use it like a SQL query:

select(['id', 'name']) .from('users') .where('id', 1); result; // [{ id: 1, name: 'TK' }] 

That's it for today. I could go on and on showing you a lot of different examples of abstractions, but I'll let you play with these concepts.

You can other articles like this on my blog.

My Twitter and Github.

Resources

  • Blog post source code
  • Closures | MDN Web Docs
  • Currying | Fun Fun Function
  • Learn React by building an App