ramda
Ramda is a utility library for writing JavaScript in a functional style. Two decisions define it. Every function is automatically curried, so calling R.filter(isActive) with one argument returns a function waiting for the list rather than throwing. And the data always goes last, so R.map(double, list) partially applies cleanly into a pipeline. Put those together and you can build transformations by naming steps instead of nesting calls: R.pipe(R.filter(isActive), R.sortBy(R.prop('name')), R.take(10)). Nothing mutates its input, so every operation returns a new value. Under that surface it is plain JavaScript objects and arrays, not custom immutable containers, and the package has zero dependencies.
Ramda is still the most complete functional toolkit for plain JavaScript, and in a JavaScript codebase committed to point-free pipelines it is a pleasure. In TypeScript, which is where most new code lives, the missing first-party types are a daily tax and remeda or es-toolkit give you the same ideas with inference that works.
Use it if
- You write data transformation pipelines and want them readable as a list of named steps, with the intermediate lambdas eliminated by currying rather than by clever nesting
- You need immutable updates to nested plain objects and do not want a proxy library: assocPath, evolve, and the lens family return new structures and leave the original alone
- You build reusable partially applied helpers, for example const activeOnly = R.filter(R.propEq('status', 'active')) shared across modules, which is where data-last argument order pays for itself
- You want deep structural equality (R.equals) and deep cloning that handle dates, regexes, and cyclic references, which JSON round-tripping and === do not
- You are in TypeScript. Ramda ships no types, so you add @types/ramda and then fight it: curried and variadic signatures are approximated with overload stacks, R.pipe loses inference past a handful of steps, and the placeholder R.__ frequently produces any. remeda and es-toolkit were written types-first and this problem disappears
- Bundle size matters and you import the namespace. import * as R from 'ramda' costs about 14 KB gzipped for the whole library, and the README itself documents that tree-shaking results vary by bundler and suggests babel-plugin-ramda or manual cherry-picking from ramda/src as workarounds
- Modern JavaScript already covers your use case. Array methods, spread, optional chaining, Object.entries, structuredClone, and Object.groupBy handle a large share of what people used Ramda for in 2016, at zero bytes and with stack traces that make sense
- You need speed on hot paths. Currying means every call goes through argument-count dispatch and returns closures; benchmarks consistently put Ramda behind native methods and behind es-toolkit and Rambda for the same operations
- Version churn worries you: after eleven years the package is still 0.32.0, so there is no major version to signal breaking changes, and functions have been removed across minor bumps (pipeP and composeP among them). The last release was October 2025
- Your team has not agreed to write point-free code. Half-adopted Ramda is worse than none: reviewers hit R.converge and R.useWith, debugging steps through anonymous curried wrappers, and the codebase ends up in two dialects
Setup reality
npm install ramda gives you a dependency-free package that works in Node, browsers, and Deno, with both CommonJS and ES module entry points already wired through the exports map. Three things trip people up on day one. Since version 0.25 there is no default export, so import R from 'ramda' silently yields undefined and you must write import * as R from 'ramda' or name the functions you want. TypeScript needs a second install of @types/ramda, which is community-maintained and lags the library. And if bundle size matters, plain named imports may still pull in everything depending on your bundler, so the README points Webpack users at babel-plugin-ramda or at scope hoisting, and notes that Rollup handles it correctly without extra configuration. There is no runtime configuration, no plugins, and no build step of your own.
Patterns
Build a transformation as a sequence of stepspipe-and-compose
import * as R from "ramda";
const topActiveNames = R.pipe(
R.filter(R.propEq("active", true)),
R.sortBy(R.prop("score")),
R.reverse,
R.take(3),
R.pluck("name")
);
topActiveNames(users); // ["Ada", "Grace", "Katherine"]pipe reads left to right, compose reads right to left, and they are otherwise identical. Only the first function may take more than one argument. Note that R.propEq takes the value first and the key second since 0.29, which is the reverse of older examples online.
Partially apply arguments in any positioncurrying-and-placeholder
import * as R from "ramda";
const add = R.curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
const greet = R.curry((greeting, name) => `${greeting}, ${name}`);
const nameFirst = greet(R.__, "Ada");
nameFirst("Hello"); // "Hello, Ada"Every Ramda function is already curried, so R.curry is only for your own. The R.__ placeholder is the escape hatch when the argument you want to fix is not the first, and it is also the construct that most often defeats TypeScript inference.
Update nested objects without mutatingimmutable-object-updates
import * as R from "ramda";
const user = { name: "Ada", address: { city: "London", zip: "NW1" } };
R.assoc("name", "Grace", user);
R.assocPath(["address", "city"], "Paris", user);
R.dissocPath(["address", "zip"], user);
R.evolve({ name: R.toUpper, address: { city: R.toLower } }, user);All of these return a new object and leave the original untouched. evolve only applies transformations to keys that are present, so it will not create missing fields, which is either exactly what you want or a silent no-op depending on the day.
Read and write a nested field through one accessorlenses
import * as R from "ramda";
const cityLens = R.lensPath(["address", "city"]);
R.view(cityLens, user); // "London"
R.set(cityLens, "Paris", user); // new object
R.over(cityLens, R.toUpper, user); // new object
const nameLens = R.lensProp("name");
const users2 = R.map(R.over(nameLens, R.trim), users);A lens bundles the getter and setter so the same value can be passed around and reused. It is a lot of machinery for one field; the payoff comes when you map the same lens across a collection or compose lenses together.
Read deep values that may not existsafe-property-access
import * as R from "ramda";
R.path(["address", "city"], user); // "London" or undefined
R.pathOr("unknown", ["address", "country"], user);
R.propOr(0, "score", user);
R.defaultTo("anon", user.nickname);
R.paths([["address", "city"], ["name"]], user);Optional chaining plus ?? covers the simple cases natively now. Where path still earns its place is point-free code, because R.path([...]) is a function you can hand to map or pipe and user?.address?.city is not.
Reshape a list into an objectgrouping-and-indexing
import * as R from "ramda";
R.groupBy(R.prop("role"), users);
// { admin: [...], viewer: [...] }
R.indexBy(R.prop("id"), users);
// { "u1": {...}, "u2": {...} }
R.countBy(R.prop("role"), users);
// { admin: 2, viewer: 7 }Keys are coerced to strings, so grouping by a numeric id gives you string keys. Object.groupBy now does the same job natively in current runtimes and returns a null-prototype object, which is safer against prototype-key collisions.
Sort by one key or severalsorting
import * as R from "ramda";
R.sortBy(R.prop("lastName"), users);
R.sortWith([
R.descend(R.prop("score")),
R.ascend(R.prop("lastName")),
], users);Both return a new array rather than sorting in place, which is the main reason to prefer them over Array.prototype.sort. sortBy compares with < and >, so mixed types and locale-sensitive strings need a custom comparator through sortWith.
Express branching as functionsconditional-logic
import * as R from "ramda";
const label = R.ifElse(
R.propSatisfies(R.gt(R.__, 100), "score"),
R.always("high"),
R.always("low")
);
const classify = R.cond([
[R.propEq("admin", "role"), R.always("staff")],
[R.propEq("viewer", "role"), R.always("guest")],
[R.T, R.always("unknown")],
]);
R.when(R.is(String), R.trim, value);cond returns undefined when no predicate matches, so always end with an R.T catch-all unless you want that. These read well inside a pipe and badly on their own; a plain if statement is clearer outside a pipeline.
Compare and copy structures deeplydeep-equality-and-clone
import * as R from "ramda";
R.equals({ a: [1, 2] }, { a: [1, 2] }); // true
R.equals(new Date(0), new Date(0)); // true
R.equals(NaN, NaN); // true
const copy = R.clone(original);equals handles dates, regexes, Map, Set, and cyclic references, and treats NaN as equal to itself, unlike ===. Both walk the whole structure, so calling equals inside a render path or a tight loop is a real cost. structuredClone covers most cloning natively now.
Compose functions that return promisesasync-pipelines
import * as R from "ramda";
const loadAndFormat = R.pipeWith(R.andThen, [
fetchUser, // returns a Promise
R.prop("profile"),
R.pick(["name", "email"]),
]);
await loadAndFormat("u1");pipeP and composeP were removed; pipeWith(andThen) is the replacement. Honestly, a plain async function with awaits is easier to read and to stack-trace, and this form buys you little beyond point-free consistency.
Map and filter in a single passtransducers
import * as R from "ramda";
const xf = R.compose(
R.filter(R.propEq("active", true)),
R.map(R.prop("score"))
);
R.into([], xf, users);
R.transduce(xf, R.add, 0, users);Transducers avoid building an intermediate array per step, which matters on large collections. Note the reversal: inside compose here, filter runs before map, which is the opposite of how compose reads everywhere else in Ramda.
Import only the functions you usecherry-pick-imports
// pulls the namespace, roughly 14 KB gzipped
import * as R from "ramda";
// named imports, tree-shaken well by Rollup and Vite
import { pipe, map, filter } from "ramda";
// guaranteed minimal, ugly at scale
import map from "ramda/src/map";The README is explicit that named imports do not by themselves guarantee a small bundle and that results depend on the bundler. Rollup and Vite handle it; older Webpack setups often need babel-plugin-ramda or the ModuleConcatenationPlugin. Measure before assuming.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| remeda | npm | You want Ramda's data-last pipelines in TypeScript with inference that actually holds, including typed pipe chains and narrowing predicates. |
| es-toolkit | npm | You mostly want fast, tree-shakeable, typed replacements for lodash-style helpers and do not need currying or point-free composition. |
| rambda | npm | You like the Ramda API but need a smaller and faster implementation with built-in TypeScript definitions, accepting a subset of the function list. |
| lodash | npm | You want the pragmatic, data-first, imperative-friendly toolkit with the largest install base and no functional-programming buy-in required. |