JavaScript and TypeScript are gloriously multi-paradigm. You can write a pure pipeline of map and reduce, then three lines later reassign a let, mutate an array in place, and throw an exception for good measure. That freedom is wonderful for prototyping and miserable for keeping a large team aligned on a consistent, predictable style. eslint-plugin-functional is the answer to "how do I get the guarantees of a language like Elm or Haskell, but incrementally, without rewriting everything?" It is a collection of ESLint rules that forbid mutation, discourage object-orientation, push toward expression-based code, and steer your codebase toward functional programming.
The package was originally born as eslint-plugin-ts-immutable before broadening its scope and adopting its current name. Today it ships under the eslint-functional org, pulls in roughly 263,000 weekly downloads, and pairs especially well with TypeScript because several of its strongest rules use full type information to reason about deep immutability. If you write Redux reducers, React state updates, or pure data pipelines, this plugin can quietly enforce the discipline you keep promising yourself you'll maintain.
Six Families of Rules
The plugin organizes its rules into six thematic categories, each with a matching preset config so you can opt into a whole family at once.
- No Mutations — the flagship category.
immutable-dataforbids mutating objects and arrays after creation,no-letbansletandvar, and the type-awareprefer-immutable-typesrequires parameters, returns, and variables to be typed at a chosen immutability level. - No Other Paradigms —
no-classes,no-class-inheritance,no-this-expressions, andno-mixed-typespush you away from OO constructs and toward plain data plus functions. - No Statements —
no-conditional-statements,no-loop-statements,no-expression-statements, andno-return-voidfavour expression-oriented code over imperative statements and side effects. - No Exceptions —
no-throw-statements,no-try-statements, andno-promise-rejectnudge you toward a Result/Either-style error model. - Currying —
functional-parametersenforces functional-friendly parameter usage, supporting a curried single-parameter style. - Stylistic —
prefer-tacitfavours point-free style andreadonly-typekeeps your readonly type syntax consistent.
A key thing to understand: many of the strongest rules are type-aware. prefer-immutable-types, type-declaration-immutability, and prefer-tacit all require typescript-eslint type information to function, which means they need a working tsconfig and are slower than purely syntactic rules.
Getting It Into Your Project
Install the plugin alongside ESLint. Note that v10 targets ESLint 9 or 10 and Node 20+, and it is flat-config only. Projects still on ESLint 8 and the legacy .eslintrc format need an earlier major of the plugin.
npm install --save-dev eslint-plugin-functional
yarn add --dev eslint-plugin-functional
If you want the type-aware rules, you'll also want typescript-eslint and a TypeScript install of at least 4.7.4 in your dev dependencies.
Wiring Up Flat Config
The recommended setup for a TypeScript project layers a few presets together in your eslint.config.js. The order matters: external recommendations first, then the plugin's own recommended and stylistic configs, then a block that enables type-aware linting.
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import functional from "eslint-plugin-functional";
import tseslint from "typescript-eslint";
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
functional.configs.externalTypeScriptRecommended,
functional.configs.recommended,
functional.configs.stylistic,
{
languageOptions: {
parserOptions: {
projectService: true, // enables type-aware linting
},
},
},
{
files: ["**/*.js"],
extends: [
tseslint.configs.disableTypeChecked,
functional.configs.disableTypeChecked,
],
},
);
That last block is the important escape hatch: on plain .js files there is no type information, so you apply disableTypeChecked to turn off the rules that would otherwise error out. The projectService: true option is what lets ESLint run the type checker, which the deep-immutability rules depend on.
If you're not using TypeScript at all, the setup is simpler. You still get all the syntactic rules like no-let, immutable-data, no-classes, and no-loop-statements, but you lose the deep-immutability checks, so you disable the type-aware rules globally.
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import functional from "eslint-plugin-functional";
export default defineConfig(
eslint.configs.recommended,
functional.configs.externalVanillaRecommended,
functional.configs.recommended,
functional.configs.stylistic,
functional.configs.disableTypeChecked,
);
Choosing How Strict to Be
The plugin ships layered preset configs so you don't have to hand-pick every rule. From gentlest to most aggressive:
lite— a gentle starting point for teams new to functional programming or migrating large existing codebases.recommended— a pragmatic balance, and the sensible default for most teams.strict— maximum functional purity, including the aggressive rules likeno-loop-statementsandno-conditional-statements.all— every rule turned on, andoffto reset everything.
There are also the per-category presets (noMutations, noOtherParadigms, noStatements, noExceptions, currying, stylistic) and the external recommendation bundles (externalTypeScriptRecommended and externalVanillaRecommended) that switch on complementary vanilla and typescript-eslint rules such as no-var, no-param-reassign, prefer-const, and @typescript-eslint/switch-exhaustiveness-check.
A realistic adoption path is to start on lite, graduate to recommended once the team is comfortable, and then cherry-pick individual stricter rules rather than flipping the whole strict preset. The strict family genuinely bans loops, if statements, throw, and classes, which can feel alien in idiomatic TypeScript.
Locking Down Data with immutable-data
The immutable-data rule is the heart of the plugin. It disallows changing data after it has been created: property assignment, delete, array mutator methods, and Object.assign onto an existing object are all flagged.
// All of these are reported by immutable-data:
const obj = { foo: 1 };
obj.foo += 2;
obj.bar = 1;
delete obj.foo;
Object.assign(obj, { bar: 2 });
const arr = [0, 1, 2];
arr[0] = 4;
arr.length = 1;
arr.push(3);
The functional alternative is to build new values instead of editing old ones:
const obj = { foo: 1 };
const arr = [0, 1, 2];
const next = { ...obj, bar: [...arr, 3, 4] };
The rule is configurable enough to survive contact with real code. ignoreImmediateMutation (on by default) permits mutating a freshly created object or array before it is assigned, so [...original].sort() is fine because you're only mutating the throwaway copy. ignoreNonConstDeclarations restricts enforcement to const bindings, ignoreClasses skips mutations inside classes, and ignoreIdentifierPattern plus ignoreAccessorPattern let you exempt specific variable names or dotted property paths (the accessor pattern supports * and ** globs, so something like mutable_* or this.** carves out an escape hatch).
Type-Aware Immutability with prefer-immutable-types
This is where TypeScript earns its keep. The prefer-immutable-types rule requires function parameters, return types, and variables to be typed at a chosen immutability level, using the is-immutable-type library to compute how immutable a type actually is. It's the more capable successor to @typescript-eslint/prefer-readonly-parameter-types.
There are four enforcement levels: None, ReadonlyShallow (surface-level readonly only), ReadonlyDeep (deeply readonly, though methods may stay mutable), and Immutable (everything deeply locked). The default global enforcement is Immutable.
// Reported: the array parameter is mutable
function process(arg: string[]) {}
// Accepted: a readonly array
function process(arg: ReadonlyArray<string>) {}
You can set a global enforcement level and then override it per context with separate parameters, returnTypes, and variables settings. The ignoreInferredTypes option skips values without explicit annotations, which is handy when external callback signatures would otherwise trip the rule. There's even a regex-based fixer/suggestions mechanism that can auto-rewrite string[] into readonly string[] for you.
Enforcing Immutability on the Types Themselves
A subtle gap in TypeScript is that readonly modifiers don't fully guarantee deep immutability, and it's easy to declare a type that looks immutable but isn't. The type-aware type-declaration-immutability rule closes that gap by checking that type declarations meet a configured immutability level, matched by name patterns.
For example, you can require that any type whose name starts with Readonly is actually ReadonlyDeep, or that every interface and type declaration be at least ReadonlyShallow. The levels come from the same is-immutable-type scale, so your declarations and your runtime values are graded on a consistent ruler. This is the rule that catches the declaration that promised immutability but quietly left a nested array mutable.
When It Fits, and When to Tread Carefully
eslint-plugin-functional shines in codebases that already lean functional: Redux reducers, React state and props handling, pure data pipelines, and libraries where predictability matters. Incremental adoption through lite then recommended is the path of least resistance, and the immutability rules pay off most around state management.
That said, it has sharp edges worth knowing. The type-aware rules add real lint-time cost because they run the full type checker, so they're slower on large repositories. Banning throw and try only makes sense if you commit to a Result/Either error model, otherwise you'll be fighting the surrounding ecosystem. And interop with class-based libraries, ORMs, framework decorators, or React class components needs the ignoreClasses and ignore*Pattern escape hatches. Most teams stay on recommended or lite and cherry-pick the stricter rules rather than embracing strict wholesale.
If you've ever wished your linter could enforce "we don't mutate state here" the same way it enforces semicolons, eslint-plugin-functional is the tool that finally makes that promise stick. Start gentle, lean on the type-aware rules where TypeScript is available, and let the linter carry the discipline so your team doesn't have to remember it on every pull request.