Programação funcional em JavaScript: além do map, filter e reduce
1. Introdução: O paradigma funcional além dos arrays
1.1. O que map, filter e reduce ensinam (e o que escondem)
Todo desenvolvedor JavaScript conhece o trio map, filter e reduce. Eles são a porta de entrada para a programação funcional, ensinando conceitos como funções puras, imutabilidade e transformação de dados. No entanto, eles escondem um universo mais profundo. Quando você domina esses métodos, descobre que eles são apenas a superfície de um paradigma que pode transformar completamente a forma como estrutura aplicações.
O problema é que map, filter e reduce operam exclusivamente sobre arrays. Eles não resolvem questões como composição de funções complexas, tratamento elegante de erros, ou manipulação imutável de objetos aninhados. Para isso, precisamos ir além.
1.2. Imutabilidade como base: por que evitar mutações vai além de métodos de array
Imutabilidade não é apenas sobre não modificar arrays. É sobre garantir que nenhum estado seja alterado acidentalmente. Considere este exemplo:
const user = { name: 'Alice', address: { city: 'SP', zip: '01001' } };
// Mutação acidental - modifica o objeto original
function updateCity(user, newCity) {
user.address.city = newCity;
return user;
}
// Abordagem imutável correta
function updateCityImmutable(user, newCity) {
return {
...user,
address: { ...user.address, city: newCity }
};
}
Aqui, o spread operator resolve um nível, mas falha em profundidade. Para objetos complexos, precisamos de técnicas mais robustas.
1.3. O ecossistema funcional no JavaScript moderno: bibliotecas e runtime
Bibliotecas como Ramda, lodash/fp, Immutable.js e Immer expandem as capacidades funcionais do JavaScript. O próprio runtime moderno oferece structuredClone e Object.freeze, mas com limitações. O ecossistema funcional vai além, oferecendo ferramentas para composição, imutabilidade profunda e tratamento de efeitos colaterais.
2. Funções de ordem superior e composição de funções
2.1. compose e pipe: construindo pipelines sem encadeamento de métodos
Enquanto map e filter usam encadeamento de métodos, compose e pipe permitem composição declarativa:
// Encadeamento tradicional
const result = [1, 2, 3, 4, 5]
.filter(n => n % 2 === 0)
.map(n => n * 2)
.reduce((sum, n) => sum + n, 0);
// Com pipe (da esquerda para direita)
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const processNumbers = pipe(
arr => arr.filter(n => n % 2 === 0),
arr => arr.map(n => n * 2),
arr => arr.reduce((sum, n) => sum + n, 0)
);
console.log(processNumbers([1, 2, 3, 4, 5])); // 12
2.2. Currying na prática: transformando funções de múltiplos argumentos em cadeias
Currying converte uma função de múltiplos argumentos em uma sequência de funções de um argumento:
// Função normal
const add = (a, b, c) => a + b + c;
// Versão curried
const curriedAdd = (a) => (b) => (c) => a + b + c;
console.log(curriedAdd(1)(2)(3)); // 6
// Uso prático: configurar funções reutilizáveis
const multiply = (a) => (b) => a * b;
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
2.3. Partial application vs. currying: quando usar cada técnica
Partial application fixa alguns argumentos de uma função, enquanto currying transforma a função em uma cadeia:
// Partial application
const partial = (fn, ...args) => (...rest) => fn(...args, ...rest);
const greet = (greeting, name) => `${greeting}, ${name}!`;
const sayHello = partial(greet, 'Hello');
console.log(sayHello('Alice')); // "Hello, Alice!"
// Currying - cada argumento é uma função separada
const curriedGreet = (greeting) => (name) => `${greeting}, ${name}!`;
const sayHi = curriedGreet('Hi');
console.log(sayHi('Bob')); // "Hi, Bob!"
Use partial application quando precisa fixar argumentos específicos. Use currying quando precisa de composição flexível e reutilização incremental.
3. Imutabilidade profunda e estruturas de dados persistentes
3.1. Object.freeze, Spread operator e structuredClone: limitações e armadilhas
const original = { a: 1, b: { c: 2 } };
// Object.freeze - apenas superficial
const frozen = Object.freeze(original);
frozen.b.c = 3; // Funciona! Apenas o primeiro nível está congelado
// Spread operator - cópia rasa
const copy = { ...original };
copy.b.c = 4; // Modifica o original também!
// structuredClone - cópia profunda, mas falha com funções e símbolos
const deepCopy = structuredClone(original);
deepCopy.b.c = 5;
console.log(original.b.c); // 2 - funciona, mas não é performático para objetos grandes
3.2. Bibliotecas como Immutable.js e Immer: como lidam com estruturas aninhadas
Immer oferece uma abordagem elegante usando proxies:
import { produce } from 'immer';
const baseState = {
users: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 }
]
};
const nextState = produce(baseState, draft => {
draft.users[0].age = 31;
draft.users.push({ name: 'Charlie', age: 28 });
});
console.log(baseState.users[0].age); // 30 - inalterado
console.log(nextState.users[0].age); // 31
3.3. Lentes (lenses) funcionais para acesso e atualização imutável de objetos
Lentes permitem focar em partes específicas de estruturas de dados:
// Implementação simples de lentes
const lens = (getter, setter) => ({
get: (obj) => getter(obj),
set: (value, obj) => setter(value, obj)
});
const nameLens = lens(
(user) => user.name,
(name, user) => ({ ...user, name })
);
const ageLens = lens(
(user) => user.age,
(age, user) => ({ ...user, age })
);
const user = { name: 'Alice', age: 30 };
// Atualização imutável via lente
const updatedUser = nameLens.set('Bob', user);
console.log(user.name); // Alice
console.log(updatedUser.name); // Bob
4. Transdutores (transducers): eficiência em transformações encadeadas
4.1. O problema de múltiplas iterações em cadeias de map/filter/reduce
Cada chamada a map ou filter cria um array intermediário:
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Três iterações separadas, três arrays intermediários
const result = numbers
.filter(n => n % 2 === 0) // [2, 4, 6, 8, 10]
.map(n => n * 3) // [6, 12, 18, 24, 30]
.reduce((sum, n) => sum + n, 0); // 90
4.2. Como transdutores eliminam arrays intermediários
Transdutores combinam transformações em uma única passagem:
const transducer = (xf) => (reducingFn) => {
return (acc, value) => {
const filtered = xf.filter(value);
if (filtered !== undefined) {
const mapped = xf.map(filtered);
return reducingFn(acc, mapped);
}
return acc;
};
};
// Implementação simplificada
function composeTransducers(...fns) {
return fns.reduce((a, b) => (...args) => a(b(...args)));
}
const filterEven = (next) => (acc, val) =>
val % 2 === 0 ? next(acc, val) : acc;
const mapTriple = (next) => (acc, val) =>
next(acc, val * 3);
const sumReducer = (acc, val) => acc + val;
const process = composeTransducers(filterEven, mapTriple)(sumReducer);
console.log([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].reduce(process, 0)); // 90
4.3. Implementação prática de um transdutor simples em JavaScript
function createTransducer(transform) {
return (reducingFn) => {
return (acc, val, idx, arr) => {
const result = transform(val);
return result !== undefined ? reducingFn(acc, result) : acc;
};
};
}
const doubleIfEven = createTransducer(val =>
val % 2 === 0 ? val * 2 : undefined
);
const addOne = createTransducer(val => val + 1);
const composed = (reducingFn) =>
doubleIfEven(addOne(reducingFn));
const result = [1, 2, 3, 4].reduce(composed((acc, val) => acc + val), 0);
console.log(result); // (2*2+1) + (4*2+1) = 5 + 9 = 14
5. Tratamento funcional de erros: Either, Maybe e Result
5.1. O padrão Maybe (Option) para evitar null checks e undefined
class Maybe {
constructor(value) {
this._value = value;
}
static of(value) {
return new Maybe(value);
}
static nothing() {
return new Maybe(null);
}
isNothing() {
return this._value === null || this._value === undefined;
}
map(fn) {
return this.isNothing() ? Maybe.nothing() : Maybe.of(fn(this._value));
}
getOrElse(defaultValue) {
return this.isNothing() ? defaultValue : this._value;
}
}
const safeDivide = (a, b) =>
b === 0 ? Maybe.nothing() : Maybe.of(a / b);
const result = Maybe.of(10)
.map(x => safeDivide(x, 2))
.map(maybe => maybe.map(x => x + 1));
console.log(result.getOrElse('Erro')); // Maybe { _value: 6 }
5.2. Either e Result: capturando erros sem exceções (try/catch)
class Either {
constructor(value) {
this._value = value;
}
static right(value) {
return new Right(value);
}
static left(value) {
return new Left(value);
}
isRight() { return this instanceof Right; }
isLeft() { return this instanceof Left; }
}
class Right extends Either {
map(fn) { return Either.right(fn(this._value)); }
catch() { return this; }
getOrElse() { return this._value; }
}
class Left extends Either {
map() { return this; }
catch(fn) { return Either.right(fn(this._value)); }
getOrElse(defaultValue) { return defaultValue; }
}
const parseJSON = (json) => {
try {
return Either.right(JSON.parse(json));
} catch (e) {
return Either.left(e.message);
}
};
const result = parseJSON('{"name": "Alice"}')
.map(data => data.name)
.getOrElse('Falha ao parsear');
console.log(result); // Alice
5.3. Monadas em JavaScript: conceito, utilidade e implementação minimalista
Uma mônada é um tipo que implementa of (ou return) e flatMap (ou chain):
class Monad {
constructor(value) {
this._value = value;
}
static of(value) {
return new Monad(value);
}
map(fn) {
return Monad.of(fn(this._value));
}
flatMap(fn) {
const result = fn(this._value);
return result instanceof Monad ? result : Monad.of(result);
}
}
// Exemplo: validação encadeada
const validateName = (name) =>
name.length >= 3 ? Monad.of(name) : Monad.of('Nome inválido');
const validateAge = (age) =>
age >= 18 ? Monad.of(age) : Monad.of('Menor de idade');
const user = Monad.of({ name: 'Alice', age: 20 })
.flatMap(u => validateName(u.name).map(n => ({ ...u, name: n })))
.flatMap(u => validateAge(u.age).map(a => ({ ...u, age: a })));
console.log(user._value); // { name: 'Alice', age: 20 }
6. Programação funcional reativa com Observables
6.1. Da função pura ao stream: como Observables estendem o paradigma
Observables representam fluxos de dados ao longo do tempo, mantendo a natureza funcional:
// Função pura tradicional
const double = (x) => x * 2;
// Observable: mesma transformação, mas sobre um fluxo
// (conceitual)
const numberStream = Observable.from([1, 2, 3, 4, 5]);
const doubledStream = numberStream.pipe(
map(x => x * 2),
filter(x => x > 5)
);
6.2. RxJS e a composição de operadores funcionais assíncronos
import { fromEvent, interval } from 'rxjs';
import { map, filter, debounceTime, distinctUntilChanged } from 'rxjs/operators';
// Exemplo: busca com debounce
const searchInput = document.getElementById('search');
fromEvent(searchInput, 'input')
.pipe(
map(event => event.target.value),
filter(text => text.length >= 3),
debounceTime(300),
distinctUntilChanged()
)
.subscribe(searchTerm => {
console.log(`Buscando por: ${searchTerm}`);
// fetchResults(searchTerm)
});
6.3. Diferença entre Promises, Async iterators e Observables no contexto funcional
| Característica | Promise | Async Iterator | Observable |
|---|---|---|---|
| Valores | Único | Múltiplos (puxados) | Múltiplos (empurrados) |
| Lazy | Não | Sim | Sim |
| Cancelável | Não | Sim | Sim |
| Operadores | .then() |
for await...of |
.pipe() com operadores |
// Promise: único valor
const promise = fetch('/api/data').then(res => res.json());
// Async Iterator: pull-based
async function* getData() {
yield await fetch('/page1');
yield await fetch('/page2');
}
// Observable: push-based com composição funcional
import { from } from 'rxjs';
import { concatMap } from 'rxjs/operators';
const pages$ = from(['/page1', '/page2']).pipe(
concatMap(url => fetch(url))
);
7. Conclusão: Quando e por que ir além do básico
7.1. Trade-offs: legibilidade vs. abstração em times JavaScript
Programação funcional avançada pode reduzir bugs, mas aumenta a curva de aprendizado. Em times JavaScript, o equilíbrio é crucial. Comece com padrões simples como pipe e currying, e avance para mônadas e transdutores apenas quando o problema justificar a complexidade.
7.2. Adoção incremental: combinando estilos funcional e imperativo
Não é necessário adotar tudo de uma vez. Comece usando funções puras e imutabilidade básica. Depois introduza compose e pipe. Gradualmente, adicione Either para tratamento de erros e, finalmente, transdutores para performance.
7.3. Recursos e bibliotecas recomendadas para aprofundamento
Para continuar seus estudos, explore Ramda para composição funcional, Immer para imutabilidade, RxJS para programação reativa, e bibliotecas como Folktale ou Crocks para tipos funcionais avançados.
Referências
- MDN Web Docs: Funções de ordem superior — Documentação oficial sobre funções de primeira classe e ordem superior em JavaScript.
- Ramda Documentation: compose e pipe — Documentação oficial da biblioteca Ramda com exemplos práticos de composição funcional.
- [Immer: Guia de imutabilidade
prática](https://immerjs.github.io/immer/) — Documentação oficial do Immer para imutabilidade simplificada.
- RxJS: Operadores de transformação — Guia completo de operadores funcionais para streams assíncronos.
- Fantasy Land Specification — Especificação de álgebra de tipos funcionais para JavaScript.
- Professor Frisby's Mostly Adequate Guide to Functional Programming — Livro gratuito e didático sobre programação funcional em JavaScript.
- You Don't Know JS: Types & Grammar — Série que aborda conceitos fundamentais de tipos, incluindo imutabilidade.
Considerações finais
A programação funcional em JavaScript vai muito além de map, filter e reduce. Ao dominar composição de funções, currying, imutabilidade profunda, transdutores, tratamento funcional de erros e Observables, você ganha ferramentas poderosas para escrever código mais previsível, testável e resiliente. Lembre-se: o objetivo não é usar todos os padrões o tempo todo, mas sim ter um arsenal de técnicas para aplicar quando fizer sentido para o problema e para o time.
Comece pequeno, experimente com projetos pessoais e, gradualmente, introduza esses conceitos em código de produção. A jornada funcional é contínua, mas cada passo traz benefícios concretos na qualidade do software que você constrói.