En el post anterior me adentré un poco en el mundo de la programación funcional (FP) explicando algunas de sus reglas, convenciones y ejemplo con dos de las funciones más usadas: curry y compose. Sin embargo me gustaría ir comentando casos prácticos para ver su verdadero potencial y el cómo puede facilitarnos reutilizar y optimizar en gran medida nuestro código. Hoy hablaré de los reducers o funciones reductoras.

Definiremos como reducer aquellas funciones que producirán un objeto en base a tres parámetros:

  • Una lista de valores o propiedades
  • Una función de transformación
  • Un valor inicial

Generalmente el caso más común es el de la función Array.prototype.reduce para generar un resultado en base a un array.

// Sumar todos los números de un array
[1, 2, 3, 4, 5].reduce((a, b) => a + b, 0) // 15

En el ejemplo podemos identificar fácilmente los 3 elementos necesarios:

  • La lista de valores [1, 2, 3, 4, 5]
  • La función de transformación (a, b) => a + b
  • El valor inicial 0

Sin embargo necesitamos los 3 elementos para poder usar la función reduce, por lo que no podríamos usarla junto a compose o curry. Para ello podemos recurrir de nuevo a alguna librería que nos provea una alternativa diseñada para FP como nuestra amiga Ramda.

Componiendo objetos con reduce

A menudo necesitamos transformar los objetos que nos provee un origen de datos externo a nuestra aplicación de modo que se adapten a lo que necesitamos. Un caso común es el de consultar una API REST para obtener los datos que necesitamos.

fetch('http://my-api.com/people/1/')  
  .then(res => res.json())
  .then(people => console.log(people))
{
  id: 1,
  name: "John Doe",
  birthDate: 157766400000,
  origin: "ES"
}

Dicha API nos provee las fechas como enteros epoch time. Podríamos tener una función que las transforme de modo que podamos manejar objetos Date nativos de Javascript.

const transformUser = (user) => Object.assign({}, user, {  
  birthDate: new Date(user.birthDate),
})

fetch('http://my-api.com/people/1/')  
  .then(res => res.json())
  .then(transformUser)
  .then(people => console.log(people))
{
  id: 1,
  name: "John Doe",
  birthDate: "1975-01-01T00:00:00.000Z",
  origin: "ES"
}

Si analizamos la función transformUser podríamos distinguir los 3 elementos que necesitamos para una función reducer:

  • Una lista de elementos: las propiedades id, name, birthDate y origin
  • Una función de transformación: Object.assign
  • Un valor inicial: user

Vamos a intentar lograr lo mismo con la función reduce ayudándonos de las funciones compose y toPairs que nos dará un array con parejas de clave-valor con las propiedades del objeto.

import { compose, reduce, toPairs } from 'ramda'


const reduceProps = (result, [key, value]) => {  
  result[key] = (key === 'birthDate') ? (new Date(value)) : value
  return result
}

const reduceUser = compose(reduce(reduceProps, {}), toPairs)

fetch('http://my-api.com/people/1/')  
  .then(res => res.json())
  .then(reduceUser)
  .then(people => console.log(people))
{
  id: 1,
  name: "John Doe",
  birthDate: "1975-01-01T00:00:00.000Z",
  origin: "ES"
}

Sencillo ¿no? Compliquémoslo más. Digamos que nuestra aplicación necesitaría disponer de un objeto con la siguiente estructura:

{
  id: 1
  firstName: "John",
  lastName: "Doe",
  age: "41 years",
  country: "Spain"
}

Prácticamente necesitamos crear un objeto completamente nuevo, la función reduceProps tiene que hacer muchas cosas y el número de propiedades que necesitemos podría incrementar con el tiempo, con lo que habría que modificarla y aumentaría la complejidad de la misma ¿Veis por donde voy?

Solución alternativa con FP

Vamos a darle la vuelta al planteamiento inicial. ¿Y si en lugar de componer el objeto a partir de lo que nos provee lo hacemos a partir de lo que necesitamos? ¿Y si pudiéramos usar la misma función no solo para nuestros usuarios sino para cualquier tipo de objeto?

En la solución que voy a proponer contaremos también con los 3 elementos necesarios del caso anterior, pero vamos a organizarlos de una manera distinta.

  • En lugar de una lista de valores tendremos una lista que describa el origen de los datos y como transformarlos a la que llamaremos meta.
  • Tendremos una función principal transform que se encargará de generar el objeto.
  • Y un objeto inicial desde donde extraeremos la información al que llamaremos origin.
import { curry, reduce, clone } from 'ramda'

const transform = curry((meta, origin) => reduce(  
  (result, { from, to, reducer, defaults }) => {
    if (origin[from]) {
      result[to||from] = reducer ? reducer(origin[from]) : clone(origin[from])
    } else if (defaults) {
      result[to||from] = clone(defaults)
    }
    return result
  }, {}, meta)
)

Básicamente transform espera recibir una lista de objetos con las propiedades from, to, reducer y defaults, y un objeto origin que usaremos como fuente de datos.

  • from será la propiedad de origin desde donde leemos el valor
  • to será un parámetro opcional para renombrar el valor en el nuevo objeto generado
  • reducer será una función opcional que transformará dicho valor, si no existe el valor se copiará tal cual usando la función clone para asegurarnos de que no haya side effects
  • defaults será un parámetro opcional que se asignará al nuevo objeto como valor por defecto en caso de que from no exista

Internamente la función usa un objeto vacío result donde irá almacenando el resultado de cada operación.

Además, la función la hemos pasado a través de curry por lo que podemos omitir el objeto origin y definir nuevas funciones que pospongan su resolución, lo cual nos da la ventaja de poder usar esta función para crear otras nuevas. Crearemos ahora una función específica reduceUser para nuestros usuarios y dos extra para simples transformaciones.

import { last, head, split, join, append, of } from 'ramda'

const getCountryByCode = code => {  
  switch (code) {
    case 'ES': return 'Spain'
    case 'US': return 'USA'
    case 'GB': return 'United Kingdom'
    case 'FR': return 'France'
    case 'DE': return 'Germany'
    default: return 'Unknown'
  }
}

const epochToYears = (epoch) => {  
  const diff = (new Date()) - (new Date(epoch))
  return parseInt(diff / 1000 / 3600 / 24 / 365.4)
}

const reduceUser = transform([  
  {
    from: 'id',
  }, {
    from: 'name',
    to: 'firstName',
    reducer: compose(head, split(' '))
  }, {
    from: 'name',
    to: 'lastName',
    reducer: compose(last, split(' '))
  }, {
    from: 'birthDate',
    to: 'age',
    // 'of' crea un array de un solo elemento
    reducer: compose(join(' '), append('years'), of, epochToYears),
    defaults: 'Private'
  }, {
    from: 'origin',
    to: 'country',
    reducer: getCountryByCode
  }
])

Y ahora probemos de nuevo con nuestra API para usuarios.

fetch('http://my-api.com/people/1/')  
  .then(res => res.json())
  .then(reduceUser)
  .then(people => console.log(people))
{ 
  id: 1,
  firstName: "John",
  lastName: "Doe",
  age: "41 years",
  country: "Spain"
}

Sencillo ¿no? ¿Y si en lugar de un usuario recibimos una lista de usuarios? Bastaría con añadir map.

import { map } from 'ramda' 

fetch('http://my-api.com/people/')  
  .then(res => res.json())
  .then(map(reduceUser))
  .then(people => console.log(people))
[{ 
  id: 1,
  firstName: "John",
  lastName: "Doe",
  age: "41 years",
  country: "Spain"
}, {
  id: 2,
  firstName: "Mary",
  lastName: "McManamara",
  age: "38 years",
  country: "United Kingdom"
}]

Añadamos nuevas funcionalidades para complicarlo más ¿Y si ahora nuestra API nos provee además una propiedad friends con una lista de amigos para cada usuario? Sólo tendríamos que modificar la función reduceUser y añadir un nuevo elemento a la lista, el resto de funciones se quedan exactamente igual. Y no solo eso, podemos reutilizar la misma función reduceUser de forma recursiva.

const reduceUser = transform([  
  {
    from: 'id',
  }, {
    from: 'name',
    to: 'firstName',
    reducer: compose(head, split(' '))
  }, {
    from: 'name',
    to: 'lastName',
    reducer: compose(last, split(' '))
  }, {
    from: 'birthDate',
    to: 'age',
    reducer: compose(join(' '), append('years'), of, epochToYears),
    defaults: 'Private'
  }, {
    from: 'origin',
    to: 'country',
    reducer: getCountryByCode
  }, {
    from: 'friends',
    reducer: map(user => reduceUser(user))
  }
])

Nota: en este caso hay que usar una función extra para usar reduceUser porque a diferencia de las funciones definidas con function, las definidas como expresión no son hoisted y provocaría un error al intentar acceder a una variable que aún no está inicializada.

import { map } from 'ramda' 

fetch('http://my-api.com/people/')  
  .then(res => res.json())
  .then(map(reduceUser))
  .then(people => console.log(people))
[{ 
  id: 1,
  firstName: "John",
  lastName: "Doe",
  age: "41 years",
  country: "Spain",
  friends: [{
    id: 3,
    firstName: "Zoe",
    lastName: "Smith",
    age: "Private",
  }]
}, {
  id: 2,
  firstName: "Mary",
  lastName: "McManamara",
  age: "38 years",
  country: "United Kingdom"
}]

Una funcionalidad nueva solo nos ha llevado una línea de código más.

Refactoring de transform

Hay un caso que no cubre nuestra función transform y es el de poder combinar varias propiedades en una, o simplemente que el reducer pueda tener la opción de conocer otras propiedades del objeto como reglas a la hora de aplicar la transformación. Lo único que necesitamos es añadir el objeto origin como segundo argumento cuando llamemos al reducer dentro de transform.

const transform = curry((meta, origin) => reduce(  
  (result, { from, to, reducer, defaults }) => {
    if (origin[from]) {
      // reducer tendrá como segundo parámetro opcional el objeto original
      result[to||from] = reducer ? reducer(origin[from], origin) : clone(origin[from])
    } else if (defaults) {
      result[to||from] = clone(defaults)
    }
    return result
  }, {}, meta)
)

Vamos a ver como podríamos usarlo. Como ejemplo podríamos especificar que los usuarios que tengan en el apellido el prefijo "Mc" y sean de "GB" se indique que son de Escocia. Vamos a crear una nueva función getLocation que acepte dos argumentos y la usaremos como reducer.

import { slice } from 'ramda'

const getLocation = (origin, user) => {  
  const preffix = compose(slice(0,2), last, split(' '))(user.name)
  if (origin === 'GB' && preffix == 'Mc') {
    return getCountryByCode(origin) + ' (Scotland)'
  } else {
    return getCountryByCode(origin)
  }
}

const reduceUser = transform([  
  {
    from: 'id',
  }, {
    from: 'name',
    to: 'firstName',
    reducer: compose(head, split(' '))
  }, {
    from: 'name',
    to: 'lastName',
    reducer: compose(last, split(' '))
  }, {
    from: 'birthDate',
    to: 'age',
    reducer: compose(join(' '), append('years'), of, epochToYears),
    defaults: 'Private'
  }, {
    from: 'origin',
    to: 'country',
    reducer: getLocation // <- Cambiamos el reducer por el nuevo
  }, {
    from: 'friends',
    reducer: map(user => reduceUser(user))
  }
])

fetch('http://my-api.com/people/')  
  .then(res => res.json())
  .then(map(reduceUser))
  .then(people => console.log(people))
[{ 
  id: 1,
  firstName: "John",
  lastName: "Doe",
  age: "41 years",
  country: "Spain", // Se mantiene igual
  friends: [{
    id: 3,
    firstName: "Zoe",
    lastName: "Smith",
    age: "Private",
  }]
}, {
  id: 2,
  firstName: "Mary",
  lastName: "McManamara",
  age: "38 years",
  country: "United Kingdom (Scotland)" // Funciona!
}]

De nuevo, una modificación con un pequeño cambio y una pequeña función extra.

Conclusión

Haciendo nuestro código modulable y orientado a FP conseguimos 3 ventajas:

  • Reutilización: En lugar de tener funciones grandes y complejas, tenemos composiciones de pequeñas funciones simples que podemos usar muchas veces.
  • Testing: Cada función es independiente y puede pasar sus propios test unitarios para garantizar su funcionamiento.
  • Reescritura: Un pequeño cambio o nueva funcionalidad no debería obligarnos a tener que hacer grandes cambios.

No hay truco, solo necesitamos adaptarnos a pensar de manera funcional en lugar de recurrir a soluciones imperativas o fuertemente orientadas a objetos. A veces solo es cuestión de darle la vuelta al planteamiento para verlo desde otra perspectiva.

"No estoy loco, ahora lo entiendo. Soy mentalmente divergente." - 12 Monkeys (1995)