Typescript: Immutable-By-Default Flags

Created on 8 Aug 2019  路  7Comments  路  Source: microsoft/TypeScript

Search Terms

readonly, default

Suggestion

Adding flags under the strict umbrella to default to immutability in different cases, and a new mutable keyword.

Use Cases

I'm creating this issue as a _parent issue_ to track a couple of issues that already exist for specific cases:

Off of the top of my head, these flags would have some direct advantages:

  • They clearly state the intention of the whole project in regards to mutability
  • It's very common for new members of teams to forget to add the readonly keyword or use T[] rather than ReadonlyArray<T> accidentally. Defaulting to immutability would help prevent these accidents, and a mutable keyword would be easy to find by tslint or even a simple grep, to automatically request review in PRs or leave an automated comment this PR introduces mutation.

Examples

Examples should probably live in the _children_ GitHub issues, but I'm copying here my comment for a quick example:

interface T {
  n: number // immutable
  mutable s: string // mutable
}

const o: T = {
  n: 42,
  s: 'hello world',
}

o.n = 43 // error
o.s = '馃憢馃寧' // ok

Checklist

My suggestion meets these guidelines:

  • [ ] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

Backwards Compatibility

@AnyhowStep made an interesting comment on this issue.

Basically this feature wouldn't be any problem for applications, but it could be problematic for libraries, as the emitted .d.ts may be imported from a library/application that isn't using this flag or is using an older TS version.

Possible solutions:

  • A pre-processor directive (https://github.com/microsoft/TypeScript/issues/32758#issuecomment-569369439)
  • Always emitting d.ts with readonly, never mutable (possibly behind another flag?)
  • downlevel-dts

Records and Tuples

The Record and Tuple proposal has reached stage 2, so it may be arriving to TS soon-ish. It seems closely related to this issue, but I don't think it fully addresses it.

Awaiting More Feedback Suggestion

Most helpful comment

Why not a lint rule where readonly is always required?

And if they want stuff to be mutable, add eslint-disable-next-line.

Not much to say about methods, though.

Three reasons:

  • The way one writes affects the way one thinks. It's harder for new team members to get into the immutability mindset if everything's mutable by default, specially if they are relatively new to TypeScript.
  • One less thing to pay attention to, less cognitive overhead.
  • Just did a quick search, there are 179 occurrences of the word readonly in one of the projects I work on daily, and it's a relatively small codebase. And it's just one project, we have a few.

All 7 comments

Why not a lint rule where readonly is always required?

And if they want stuff to be mutable, add eslint-disable-next-line.

Not much to say about methods, though.

Why not a lint rule where readonly is always required?

And if they want stuff to be mutable, add eslint-disable-next-line.

Not much to say about methods, though.

Three reasons:

  • The way one writes affects the way one thinks. It's harder for new team members to get into the immutability mindset if everything's mutable by default, specially if they are relatively new to TypeScript.
  • One less thing to pay attention to, less cognitive overhead.
  • Just did a quick search, there are 179 occurrences of the word readonly in one of the projects I work on daily, and it's a relatively small codebase. And it's just one project, we have a few.

I've started using prefer-readonly-type with a small project (9K SLOC) and ended up having to add a 346 readonlys throughout the code and 18 // eslint-disable-next-line functional/prefer-readonly-type comments. I'd much rather only have to add 18 mutables instead. The code would be much cleaner and easier to maintain.

One of the reasons for the high readonly count is that the prefer-readonly-type doesn't support wrapping objects with Readonly<>.

I use readonly as much as possible, myself. I have 1019 usages of readonly in a hobby project.
But I still think this is better as a lint rule (at the moment).

It seems like people don't agree with me, so I figured I'd elaborate.


If library A uses this flag, the emit would still have to be compatible with downstream libraries, whether they use this flag or not.

So, now, library A's .d.ts emit has to look like this,

export type Immutable = { readonly x : number };
export type Mutable = { mutable x : number };

If it was this instead,

export type Immutable = { readonly x : number };
export type Mutable = { x : number };

Then downstream libraries that use the flag will think Mutable is immutable.


If it was this instead,

export type Immutable = { x : number };
export type Mutable = { mutable x : number };

Then downstream libraries that do not use the flag will think Immutable is mutable.


Of course, with the introduction of this mutable keyword, you now have a breaking change. One that might not be justifiable because of how much impact it has.

Projects on TS-without-mutable-keyword suddenly can't use projects compiled by TS-with-mutable-keyword. And not everyone upgrades their project's version of TS as quickly as the releases come.


I can't think of a way to introduce this flag without breaking .d.ts files, somehow.
The ideal implementation would,

  • Be compatible with older versions of TS (No new keywords/property modifiers)
  • Be compatible with projects that turn off this flag
  • Be compatible with projects that turn on this flag

You could maybe have some kind of TS-specific pre-processor directive in the emit that says, "Treat the following type as mutable". Then, your emit would be,

export type Immutable = { readonly x : number };
/* magic-pre-processor-directive-that-says-following-type-is-mutable */
export type Mutable = { x : number };
  • Older versions of TS correctly see Mutable as being mutable.

    No behaviour has changed. It looks like regular emit.

  • Newer versions of TS with the flag correctly see Mutable as being mutable.

    With the flag switched on, it should think that Mutable is immutable, since readonly is the default property modifier. But the magical comment should force TS to treat Mutable as having mutable properties by default.

  • Newer versions of TS without the flag correctly see Mutable as being mutable.

    Same as older versions of TS.

However, this requires that magical pre-processor directive. I'm not sure if that's a thing the TS team would enjoy having.

Thanks @AnyhowStep for that thorough analysis! Initially I didn't realize this would be a breaking change. I've added your comment to the issue's description, along with a possible solution.

Oh, wait. downlevel-dts is a thing.

https://github.com/sandersn/downlevel-dts

Even if new emit is incompatible, downlevel-dts can be used.

Making readonly the default will make TypeScript a lot more powerful. In our codebases we use prefer-readonly-type to automatically add readonly to everything, and we don't have any eslint-ignore. Still, it's annoying to see and read all those Readonly.

If TypeScript wants to be here to stay it must go through this soon. I'm shocked to see 2020 code that uses mutations. I thought that already behind us.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jbondc picture jbondc  路  3Comments

dlaberge picture dlaberge  路  3Comments

manekinekko picture manekinekko  路  3Comments

Zlatkovsky picture Zlatkovsky  路  3Comments

MartynasZilinskas picture MartynasZilinskas  路  3Comments