tslint-immutable
TSLint rules to disable mutation in TypeScript.
Background
In some applications it is important to not mutate any data, for example when using Redux to store state in a React application. Moreover immutable data structures has a lot of advantages in general so I want to use them everywhere in my applications.
I originally used immutablejs for this purpose. It is a really nice library but I found it had some drawbacks. Specifically when debugging it was hard to see the structure, creating JSON was not straightforward, and passing parameters to other libraries required converting to regular mutable arrays and objects. The seamless-immutable project seems to have the same conclusions and they use regular objects and arrays and check for immutability at run-time. This solves all the aformentioned drawbacks but introduces a new drawback of only being enforced at run-time. (Altough you loose the structural sharing feature of immutablejs with this solution so you would have to consider if that is something you need).
Then typescript 2.0 came along and introduced readonly options for properties, indexers and arrays. This enables us to use regular object and arrays and have the immutability enfored at compile time instead of run-time. Now the only drawback is that there is nothing enforcing the use of readonly in typescript.
This can be solved by using linting rules. So the aim of this project is to leverage the type system in typescript to enforce immutability at compile-time while still using regular objects and arrays.
Installing
npm install tslint-immutable --save-dev
See the example tslint.json file for configuration.
Compability
- tslint-immutable 3.x.x is compatible with tslint 5.x.x.
- tslint-immutable 2.x.x is compatible with tslint 4.x.x.
- tslint-immutable 1.x.x is compatible with tslint 3.x.x.
TSLint Rules
In addition to immutable rules this project also contains a few rules for enforcing a functional style of programming and a few other rules. The following rules are available:
Immutability rules
readonly-interface
This rule enforces having the readonly
modifier on all interface members.
You might think that using const
would eliminate mutation from your TypeScript code. Wrong. Turns out that there's a pretty big loophole in const
.
interface Point { x: number, y: number }
const point: Point = { x: 23, y: 44 };
point.x = 99;
This is why the readonly-interface
rule exists. This rule prevents you from assigning a value to the result of a member expression.
interface Point { readonly x: number, readonly y: number }
const point: Point = { x: 23, y: 44 };
point.x = 99;
This rule is just as effective as using Object.freeze() to prevent mutations in your Redux reducers. However this rule has no run-time cost, and is enforced at compile time. A good alternative to object mutation is to use the ES2016 object spread syntax that was added in typescript 2.1:
interface Point { readonly x: number, readonly y: number }
const point: Point = { x: 23, y: 44 };
const transformedPoint = { ...point, x: 99 };
readonly-indexer
This rule enforces all indexers to have the readonly modifier.
let foo: { [key:string]: number };
let foo: { readonly [key:string]: number };
readonly-array
This rule enforces use of ReadonlyArray<T>
instead of Array<T>
or T[]
.
Even if an array is declared with const
it is still possible to mutate the contents of the array.
interface Point { readonly x: number, readonly y: number }
const points: Array<Point> = [{ x: 23, y: 44 }];
points.push({ x: 1, y: 2 });
Using the readonly-array rule will stop this mutation:
interface Point { readonly x: number, readonly y: number }
const points: ReadonlyArray<Point> = [{ x: 23, y: 44 }];
points.push({ x: 1, y: 2 });
Has Fixer
Yes
Options
Example config
"readonly-array": true
"readonly-array": [true, "ignore-local"]
"readonly-array": [true, "ignore-local", {"ignore-prefix": "mutable"}]
no-let
This rule should be combined with tslint's built-in no-var-keyword
rule to enforce that all variables are declared as const
.
There's no reason to use let
in a Redux/React application, because all your state is managed by either Redux or React. Use const
instead, and avoid state bugs altogether.
let x = 5;
What about for
loops? Loops can be replaced with the Array methods like map
, filter
, and so on. If you find the built-in JS Array methods lacking, use ramda, or lodash-fp.
const SearchResults =
({ results }) =>
<ul>{
results.map(result => <li>result</li>) // <- Who needs let?
}</ul>;
Functional style rules
no-this, no-class, no-new
Thanks to libraries like recompose and Redux's React Container components, there's not much reason to build Components using React.createClass
or ES6 classes anymore. The no-this
rule makes this explicit.
const Message = React.createClass({
render: function() {
return <div>{ this.props.message }</div>;
}
})
Instead of creating classes, you should use React 0.14's Stateless Functional Components and save yourself some keystrokes:
const Message = ({message}) => <div>{ message }</div>;
What about lifecycle methods like shouldComponentUpdate
? We can use the recompose library to apply these optimizations to your Stateless Functional Components. The recompose library relies on the fact that your Redux state is immutable to efficiently implement shouldComponentUpdate for you.
import { pure, onlyUpdateForKeys } from 'recompose';
const Message = ({message}) => <div>{ message }</div>;
const OptimizedMessage = pure(Message);
const HyperOptimizedMessage = onlyUpdateForKeys(['message'], Message);
no-mixed-interface
Mixing functions and data properties in the same interface is a sign of object-orientation style. This rule enforces that an inteface only has one type of members, eg. only data properties or only functions.
no-expression-statement
When you call a function and don’t use it’s return value, chances are high that it is being called for its side effect. e.g.
array.push(1)
alert('Hello world!')
This rule checks that the value of an expression is assigned to a variable and thus helps promote side-effect free (pure) functions.
Other rules
no-arguments
Disallows use of the arguments
keyword.
no-label
Disallows the use of labels, and indirectly also goto
.
no-semicolon-interface
Ensures that interfaces only use commas as separator instead semicolor.
inferface Foo {
bar: string;
zoo(): number;
}
inferface Foo {
bar: string,
zoo(): number,
}
import-containment
ECMAScript modules does not have a concept of a library that can span multiple files and share internal members. If you have a set of files that forms an library, and they need to be able to call each other internally without exposing members to other files outside the library set, this rule can be useful.
Options
Using the ignore-local
option
If a tree falls in the woods, does it make a sound?
If a pure function mutates some local data in order to produce an immutable return value, is that ok?
The quote above is from the clojure docs. In general, it is more important to enforce immutability for state that is passed in and out of functions than for local state used for internal calculations within a function. For example in Redux, the state going in and out of reducers needs to be immutable while the reducer may be allowed to mutate local state in its calculations in order to achieve higher performance. This is what the ignore-local
option enables. With this option enabled immutability will be enforced everywhere but in local state. Function parameters are not considered local state so they will still be checked.
Note that using this option can lead to more imperative code in functions so use with care!
Using the ignore-prefix
option
Some languages are immutable by default but allows you to explicitly declare mutable variables. For example in reason you can declare mutable record fields like this:
type person = {
name: string,
mutable age: int
};
Typescript is not immutable by default but it can be if you use this package. So in order to create an escape hatch similar to how it is done in reason the ignore-prefix
option can be used. For example if you configure it to ignore variables with names that has the prefix "mutable" you can emulate the above example in typescript like this:
type person = {
readonly name: string,
mutableAge: number
};
Yes, variable names like mutableAge
are ugly, but then again mutation is an ugly business :-).
Recommended built-in rules
Without this rule, it is still possible to create var
variables that are mutable.
typedef with call-signature option
For performance reasons, tslint-immutable does not check implicit return types. So for example this function will return an mutable array but will not be detected (see #18 for more info):
function foo() {
return [1, 2, 3];
}
To avoid this situation you can enable the built in typedef rule like this:
"typedef": [true, "call-signature"]
Now the above function is forced to declare the return type becomes this and will be detected.
Sample Configuration File
Here's a sample TSLint configuration file (tslint.json) that activates all the rules:
{
"extends": [
"tslint-immutable"
],
"rules": {
"no-var-keyword": true,
"typedef": [true, "call-signature"],
"readonly-interface": true,
"readonly-indexer": true,
"readonly-array": true,
"no-let": true,
"no-this": true,
"no-class": true,
"no-new": true,
"no-mixed-interface": true,
"no-expression-statement": true,
"no-arguments": true,
"no-label": true,
"no-semicolon-interface": true,
"import-containment": [ true,
{
"containmentPath": "path/to/libs",
"allowedExternalFileNames": ["index"],
"disallowedInternalFileNames": ["index"]
}]
}
}
How to contribute
For new features file an issue. For bugs, file an issue and optionally file a PR with a failing test. Tests are really easy to do, you just have to edit the *.ts.lint
files under the test directory. Read more here about tslint testing.
How to develop
To execute the tests run yarn test
.
To release a new package version run yarn publish:patch
, yarn publish:minor
, or yarn publish:major
.
Prior work
This work was originally inspired by eslint-plugin-immutable.