Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[prefer-readonly-type] Rework this rule. #153

Closed
RebeccaStevens opened this issue Sep 21, 2020 · 18 comments · Fixed by #480
Closed

[prefer-readonly-type] Rework this rule. #153

RebeccaStevens opened this issue Sep 21, 2020 · 18 comments · Fixed by #480
Labels
Breaking Change This change will require a new major release. Feature Removal A feature is no longer wanted/needed (remove/deprecate it) Status: In Progress Issue is currently being resolved by a developer. Type: Enhancement Enhancement of the code, not introducing new features.

Comments

@RebeccaStevens
Copy link
Collaborator

RebeccaStevens commented Sep 21, 2020

Function Return Types

The return type of functions shouldn't be enforced to be immutable (by default) (this behavior can currently be turned on with the allowMutableReturnType option).

Following from this, the rule @typescript-eslint/explicit-function-return-type should no longer be a recommend external rule.

Enforce Mutable Return Types

If the return type has to immutable then something very non-functional is going on. What happens to the data after it is returned by a function should be of no concern to the function itself. Mutable return types are more compatible with 3rd-party non-function code as readonly types can not be given as mutable types in TypeScript (while mutable types can be given as either a mutable or readonly type).

An option to enforce this behaviour should be added to the rule (whether this is the default behavior or not is currently undecided).

Type Aliases

Often a mutable type needs to be aliased. This rule should not trigger an error when a mutable type is aliased (by default), only when the mutable type is used.

Care will be need to be taken when implementing this to keep performance good - we don't want to reevaluate every instance of type alias usage to check whether it's mutable or not.

New Rule

As this is such a large change to the current implementation of rule, it's probably best to instead create this change as a new rule and deprecate the current rule. Any suggestions on a new rule name?

@RebeccaStevens RebeccaStevens added Type: Enhancement Enhancement of the code, not introducing new features. Discussion Feature Removal A feature is no longer wanted/needed (remove/deprecate it) Breaking Change This change will require a new major release. labels Sep 21, 2020
@RebeccaStevens RebeccaStevens self-assigned this Sep 21, 2020
@RebeccaStevens
Copy link
Collaborator Author

@jonaskello Thought's on this proposal?

@jonaskello
Copy link
Collaborator

If the return type has to immutable then something very non-functional is going on.

I agree with this :-). The rule was created to avoid non-functional stuff from happening. Especially on teams where developers have limited experience with fp. If you cannot mutate the return value then you are left with the option to pass it to another function for transformation or overriding the rule. It helps teams to treat everything as an immutable expression that you can only pass on to some other function. I think it can help teach teams to start thinking more in functional programming terms. If you use a fp language everything is immutable, including return values.

What happens to the data after it is returned by a function should be of no concern to the function itself.

This is also true and if you can use discipline to not mutate the return value (eg. you are somewhat experienced in fp) then the rule is probably not needed.

Mutable return types are more compatible with 3rd-party non-function code as readonly types can not be given as mutable types in TypeScript (while mutable types can be given as either a mutable or readonly type).

I agree this is a problem and probably the main reason to consider not using this rule. IMO the rule is good for application code but when doing a lot of integration with 3rd party libs it can become a pain as you have to override it often. I have not had a lot of problem with this in my projects but perhaps we can collect some samples of where it is hard to have this rule enabled?

Often a mutable type needs to be aliased. This rule should not trigger an error when a mutable type is aliased (by default), only when the mutable type is used.

I'm not sure I follow on this, could you give an example to clarify?

@RebeccaStevens
Copy link
Collaborator Author

So it seems like the original rule was designed around the idea of "make everything immutable so we can guarantee that mutations aren't happening", while this new proposal is more around the idea of "make things we are using immutable so that we aren't mutating things, don't care what others do" (I don't think that's the best 1 line description but it will do for now).

The "downside" to this new approach is when using your own functions, you need to recast the return values as immutable. For example:

// myFunc returns a mutable type.
const mutableFoo = myFunc(1, 2, 3);
const foo = mutableFoo as DeepReadoly<typeof mutableFoo>;
// ...

However, this same issue already exist when using 3rd party functions as they also return mutable types. But the advantage is that the 3rd party isn't enforcing any usage constrains on the end user. So this "downside" should really be seen as a good thing rather than a negative.

Note: If using this rule with the "immutable-data" rule, then there is no need to recast values like as done above.

Often a mutable type needs to be aliased. This rule should not trigger an error when a mutable type is aliased (by default), only when the mutable type is used.

Example:

type SomeType = Array<SomeOtherType>;
//              ^^^^^^^^^^^^^^^^^^^^  - Currently this is marked as an issue, new proposal is to not mark this (by default).

function foo(bar: SomeType): SomeType {
//                ^^^^^^^^            - Currently this is not an issue as it is assumed the type alias is immutable, new proposal is to mark this.
//                           ^^^^^^^^ - Return type isn't marked in either case (using default config).
}

RebeccaStevens added a commit that referenced this issue Jul 25, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Jul 30, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Jul 31, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Jul 31, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Jul 31, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Jul 31, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Jul 31, 2021
…y default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
@RebeccaStevens
Copy link
Collaborator Author

RebeccaStevens commented Jul 31, 2021

@jonaskello I'm thinking that it might best to deprecate this rule in favor of @typescript-eslint/prefer-readonly-parameter-types.

The core reason for this rule is to do what that rule is doing. The additional things this rule is doing is pretty much things that I would be taking out in the rewrite as talked about above.

@jonaskello
Copy link
Collaborator

jonaskello commented Aug 2, 2021

I think I understand better what the proposal is now. So instead of explicitly requiring types/interfaces to be declared with readonly attributes, the @typescript-eslint/prefer-readonly-parameter-types rule would implicitly enforce this requirement by not allowing you to pass anything that is not declared deeply readonly into any of your functions?

I'm hesitant to remove/deprecate the rules that requires explicit readonly attributes or ReadonlyArray types in case of array etc. These were the first rules added to tslint-immutable and I know they were was appreciated by many users. I think it serves a purpose to train/encourage junior developers to make things immutable at the time they write code that declare the types rather than later when they use the type. It also provides a quick-fix to add the readonly attribute that I use daily when I declare types (since I am lazy and don't want to type it out myself :-)). There are also cases where you would declare types but not pass them into functions but they are of course rare, eg. when declaring a global const and referencing it directly in a function. As you mentioned the idea of the rule was to make everything immutable, except types/fields explicitly marked mutable. Kind of how it works in some functional-first languages like ocaml/reasonml. Typescript does not have a mutable attribute but to mark types/fields mutable you would name them in a pattern that is ignored by the prefer-readonly-type rule.

So from the above I think you can have two different approaches to immutability.

The first approach would be to explicitly require declaration of readonly types everywhere and when you need mutable types (eg when using 3rd party functions) you need to cast them to non-readonly types (which admittedly is ugly) or mark them as mutable by naming convention.

The second approach would be to only require readonly types to be passed into your own functions which could avoid some pain when calling 3rd party functions.

I think we should support both these approaches. By enabling both prefer-readonly-type and @typescript-eslint/prefer-readonly-parameter-types you can use the first approach, and by only enabling @typescript-eslint/prefer-readonly-parameter-types you could use the second approach.

However I can see how the rules immutable-data, prefer-readonly-type, and @typescript-eslint/prefer-readonly-parameter-types overlap in some regards and requires/checks the same things but in different ways, which adds overhead to the linting and I think this is what you are trying to solve by deprecating prefer-readonly-type? I think this is something we could investigate further to improve somehow.

Please let me know if I misunderstood anything :-).

@RebeccaStevens
Copy link
Collaborator Author

I'm thinking that we might want to add a new rule that deals with ensuring type aliases are readonly/mutable based on some condition. Then that rule in conjunction with prefer-readonly-parameter-types would replace prefer-readonly-type.

For example:
This new rule could be configured to ensure that any type alias that starts with Mutable is not readonly while all others are. Or alternatively, any type alias that starts with Readonly is readonly while all others aren't.

@RebeccaStevens RebeccaStevens removed their assignment Aug 3, 2021
@RebeccaStevens
Copy link
Collaborator Author

I made a start on this new rule. Right now the working name is prefer-readonly-type-alias.

@RebeccaStevens
Copy link
Collaborator Author

@jonaskello The new rule is mostly done now; just small tweaks to go and waiting on some upstreams.
Essentially the rule is the same as prefer-readonly-type except it only works on Type Aliases and Interfaces.

This new rule plus @typescript-eslint/prefer-readonly-parameter-types should be able to replace the current rule. Let me know what you think.

@jonaskello
Copy link
Collaborator

I'm trying to understand the implcations of using the @typescript-eslint/prefer-readonly-parameter-types + prefer-readonly-type-alias (the new rule) combo vs using prefer-readonly-type.

Looking at the rule creation code seems like a good place to start:

prefer-readonly-type

export const rule = createRule<keyof typeof errorMessages, Options>(
  name,
  meta,
  defaultOptions,
  {
    ArrowFunctionExpression: checkImplicitType,
    ClassProperty: checkProperty,
    FunctionDeclaration: checkImplicitType,
    FunctionExpression: checkImplicitType,
    TSArrayType: checkArrayOrTupleType,
    TSIndexSignature: checkProperty,
    TSParameterProperty: checkProperty,
    TSPropertySignature: checkProperty,
    TSTupleType: checkArrayOrTupleType,
    TSMappedType: checkMappedType,
    TSTypeReference: checkTypeReference,
    VariableDeclaration: checkImplicitType,
  }
);

prefer-readonly-type-alias

export const rule = createRule<keyof typeof errorMessages, Options>(
  name,
  meta,
  defaultOptions,
  {
    TSArrayType: checkArrayOrTupleType,
    TSIndexSignature: checkProperty,
    TSInterfaceDeclaration: checkTypeAliasDeclaration,
    TSMappedType: checkMappedType,
    TSParameterProperty: checkProperty,
    TSPropertySignature: checkProperty,
    TSTupleType: checkArrayOrTupleType,
    TSTypeAliasDeclaration: checkTypeAliasDeclaration,
    TSTypeReference: checkTypeReference,
  }
);

I'll enumerate the differences here for reference:

Removed

    ArrowFunctionExpression: checkImplicitType,
    ClassProperty: checkProperty,
    FunctionDeclaration: checkImplicitType,
    FunctionExpression: checkImplicitType,
    VariableDeclaration: checkImplicitType,

Added

    TSInterfaceDeclaration: checkTypeAliasDeclaration,
    TSTypeAliasDeclaration: checkTypeAliasDeclaration,

Would this be a correct assessment of the code changes?

@RebeccaStevens
Copy link
Collaborator Author

RebeccaStevens commented Aug 7, 2021

ArrowFunctionExpression, FunctionDeclaration, FunctionExpression are essentially checked by @typescript-eslint/prefer-readonly-parameter-types (just not the return type).

So the main difference will be that variable declarations will not be checked (const foo: Array<number> = ... edit: only implicit mutable arrays will not be checked) but the immutable-data rule can basically handle that.

Also class properties are no longer checked.

@RebeccaStevens
Copy link
Collaborator Author

We can also keep both rules around while we gather feedback from users.

@jonaskello
Copy link
Collaborator

jonaskello commented Aug 7, 2021

From what I understand we have this now then:

{
    // Covered in the same way by new rule prefer-readonly-type-alias
    TSArrayType: checkArrayOrTupleType,
    TSIndexSignature: checkProperty,
    TSParameterProperty: checkProperty,
    TSPropertySignature: checkProperty,
    TSTupleType: checkArrayOrTupleType,
    TSMappedType: checkMappedType,
    TSTypeReference: checkTypeReference,
    // Covered by @typescript-eslint/prefer-readonly-parameter-types except return types
    ArrowFunctionExpression: checkImplicitType,
    FunctionDeclaration: checkImplicitType,
    FunctionExpression: checkImplicitType,
    // Not covered
    VariableDeclaration: checkImplicitType,
    ClassProperty: checkProperty,
}

I think we should keep the original intent of the rule to have everything possible declared as readonly types. The immutable-data rule can check for mutation of variables but not by declaration of readonly types? So checking for declaration of readonly types for return types and variable declarations would need to be added to the new rule to have full support for declarative readonly type checking? Declaration of Set and Map checking should probably be added too? (set and map are already added :-)) I myself try to avoid classes as far as possible but I think other users may still want class properties checked.

Perhaps the new rule should have a name like prefer-readonly-declarations or something like that to signal it tries to require declaration of readonly types in all places typescript allows it?

@jonaskello
Copy link
Collaborator

jonaskello commented Aug 7, 2021

I think the intent of the rule would be that if you are explicitly declaring something, it should be checked to be a readonly declaration. If you are implicitly inferring a type it should not be checked by this rule. In the implicit case you could either cover it by the immutable-data rule or you could enable rules that require explicit declarations and thereby have them checked. Eg. I usually enable rules to require explicit return type declaration in order to have the return type checked.

@RebeccaStevens
Copy link
Collaborator Author

My intention behind switching to the new rule and prefer-readonly-parameter-types was essentially that it makes work with 3rd-party libraries that aren't functional or provide readonly versions of their types vastly easier.
I wasn't really considering the case of using them in a purely functional environment (which is what the current rule is really good at).

My current thoughts are now that we should not deprecate prefer-readonly-type and instead have the 2 rules co-exist.

I have also been thinking of introducing a new config/preset in the next major release. Currently we have "recommended" and "lite"; I was think of adding a new "strict" one. This strict preset will focus on enforcing functional linting rules very harshly and I think it would be a good place to include the current prefer-readonly-type rule. While prefer-readonly-type-alias would be used in the recommended and lite presets.

But as these rules can't really be used together, maybe it would be best to just create a new rule that merges the two and allows for it to be configure either way. I'll have to do a bit more thinking on this about how such as rule would be implemented.

@jonaskello
Copy link
Collaborator

Yes, at work we usually do projects in a "functional core, imperative shell" style where the all the core application code is more purely functional and the integration with 3rd party libs are pushed to the corners of the app (as 3rd party libs usually are careless with side-effects). For example for client-side we use typescript-tea which is just an implementation of the elm arch which enables all side-effects (and 3rd party lib integrations) to live in effect managers outside the core application code. I've also experimented with using iterators to emulate algebraic effects to get the same separation on the server-side. So for these environments we would like to lint all application code more strictly as it never calls into 3rd party code directly.

I think merging to one rule with more options would be a good idea. If there is overlap with @typescript-eslint/prefer-readonly-parameter-types we could probably remove those checks to not have to maintain code that checks things available in an official rule.

@RebeccaStevens
Copy link
Collaborator Author

Working on the merged rule now. Seems to be coming along pretty well so far.

RebeccaStevens added a commit that referenced this issue Aug 8, 2021
…by default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
@RebeccaStevens
Copy link
Collaborator Author

@jonaskello Merged rule should be pretty much finished now #256
The rule is just prefer-readonly-type with updates; no new rules.

@RebeccaStevens
Copy link
Collaborator Author

@jonaskello I've been thinking that it might be best to rename the rule to prefer-readonly-type-declarations and keep the original prefer-readonly-type rule around but deprecated. My reasoning being that there are a few breaking changes between the new and old rules. Do you think this would be the way to go about things?

RebeccaStevens added a commit that referenced this issue Aug 12, 2021
…by default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
RebeccaStevens added a commit that referenced this issue Aug 12, 2021
…by default

BREAKING CHANGE: allowMutableReturnType is now on by default

re #153
@RebeccaStevens RebeccaStevens added the Status: In Progress Issue is currently being resolved by a developer. label Jul 19, 2022
@RebeccaStevens RebeccaStevens mentioned this issue Sep 24, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Breaking Change This change will require a new major release. Feature Removal A feature is no longer wanted/needed (remove/deprecate it) Status: In Progress Issue is currently being resolved by a developer. Type: Enhancement Enhancement of the code, not introducing new features.
Projects
None yet
2 participants