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

feat: make generated classes sealed/final #1121

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

Yegair
Copy link

@Yegair Yegair commented Aug 31, 2024

Adds a new configuration parameter finalize: bool that allows to mark generated classes as final in the case of concrete classes or sealed in the case of abstract classes. It is disabled by default.

Enabling it allows the Dart analyzer/compiler to report patterns that will never match the type of a freezed class at compile time.

For example:

@Freezed(finalize: true)
sealed class Foo with _$Foo {
  const Foo._();
  const factory Foo() = _Foo;
}

@Freezed(finalize: true)
sealed class Bar with _$Bar {
  const Bar._();
  const factory Bar() = Bar;
}

void main() {
  switch (Foo()) {
    // This will now cause an analyzer warning: pattern_never_matches_value_type.
    // Without finalize: true it would not cause any warning/error at all.
    case Bar():
      print("matched Bar()");
      return;

    case Foo():
      print("matched Foo()");
      return;
  }
}

Why do I think this is a good idea?

I have recently had an issue in one of my projects where I have a @freezed sealed class DataFromApi and I introduced a new @freezed sealed class DataFromDatabase that was intended to be used in many but not all cases where DataFromApi was being used so far.

It was a normal change, but I messed up due to the heavy use of pattern matching like

switch (data) {
  case DataFromApi(someCondition: true):
    // ...
    break;

  case DataFromApi(someCondition: false):
    // ...
    break;

  default:
    // ...
    break;
}

The changes mostly looked like this

- final data = await loadDataFromApi();
+ final data = await loadDataFromDatabase();

switch (data) {
  case DataFromApi(someCondition: true):
    // ...
    break;

    // ...
}

I was expecting the Dart analyzer/compiler to yell at me when changing the type of data in the example above to DataFromDatabase, but that was not the case. So I had to find all the now broken patterns by hand. Naturally I overlooked one case and the tests didn't catch it, so I got a Bug in production 😅.

IMHO the Dart analyzer/compiler should be able to help me in this case no matter if I use freezed or not, since the classes in question were marked as sealed. So I opened an issue over there: dart-lang/sdk#56616

However, since this might never be implemented or maybe is completely impossible, I thought it should be possible and rather simple to make freezed help me out in that case. All that needs to be done is mark the generated classes as final or sealed, and then the analyzer will step in and report any pattern that can never match at compile time.

What needs to be done before it might be merged?

Good question, I think first of all someone needs to decide if this even is a good idea or if it would mess things up. In case it should be merged, I assume a few more tests need to be written to make sure the finalize flag works well with other configuration options. However, so far I have a hard time figuring out what even might break due to this change, so I would need help to decide what special/edge cases to consider when writing tests.

Summary by CodeRabbit

  • New Features

    • Enhanced class immutability: Classes marked as sealed are now generated as final, ensuring improved consistency.
    • Introduced new sealed class patterns with support for single and multiple constructors, offering more streamlined usage.
  • Tests

    • Added tests to verify that pattern matching on sealed classes produces the expected warnings for unmatched cases.

Copy link

changeset-bot bot commented Aug 31, 2024

⚠️ No Changeset found

Latest commit: 4774262

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@rrousselGit
Copy link
Owner

This is valuable.

Although I'm wondering whether we need a flag at all. Why not always make the generated class final if it helps?
Extending a Freezed class isn't supported anyway, as they only have factory constructors.

@Yegair
Copy link
Author

Yegair commented Aug 31, 2024

Happy to hear that 😊

If I would implement that change in the scope of my project I would certainly make final/sealed the default. However, I find it very hard to anticipate how freezed is being used out there in the wild and making it the default feels like doing a breaking change. So I would ask you to make that decision, but for me at least it would definitely work.

@Yegair
Copy link
Author

Yegair commented Sep 1, 2024

From my side the PR is now "ready", meaning I have no further actionable task on my list.

Would be happy to remove a whole bunch of code and just hardcode sealed and final when generating subclasses, but as I said before: I would 100% support this, but I don't think I am in a position to make that decision.

Tried to make the CI work, but I guess the reason it failed has nothing to do with my changes, so I just added a dependency override for frontend_server_client: ^4.0.0. However, I am unsure if this might have negative side effects, so would be happy to remove it if requested.

Also I was thinking if finalize is a good term to use (I'm not a native english speaker), but maybe makeGeneratedClassesFinal or something in this direction would be easier to understand for users?

And finally, I tried to add a changeset as the bot requested, but I was unable to make it work by following the guide it linked to, so I assume using this package is somewhat outdated?

@rrousselGit
Copy link
Owner

Would be happy to remove a whole bunch of code and just hardcode sealed and final when generating subclasses, but as I said before: I would 100% support this, but I don't think I am in a position to make that decision.

You can go ahead and do this change. Only _$Foo classes are sub-classable at the moment, and that's not something folks should be using anyway.

Removing the flag and making the generated classes final are beneficial to more people this way. Otherwise most folks won't even know that the option is there and won't benefit from the warning.

@rrousselGit
Copy link
Owner

You can ignore the changeset bot btw

@Yegair Yegair changed the title feat: add finalize parameter to Freezed config feat: make generated classes sealed/final Sep 2, 2024
@Yegair
Copy link
Author

Yegair commented Sep 2, 2024

Removed the configuration part, so basically it now just adds the final/sealed modifiers and of course left in some tests that make sure it works as intended.

I think it should be ready to merge.

If there is something else that should be changed, just let me know, but throughout the week it might take a while until I can get to it.


And since I get the chance to talk to you directly, just wanted to let you know how valuable Freezed and especially Riverpod are to the project I am working on (https://choosy.de if you want to know what people are building with your libs 😉). Since migrating from Bloc to Riverpod the ease of using providers from inside other providers made such a huge difference.

Cant wait to get a first glimpse of the wizardry you (hopefully) are going to do with macros once they are stable 😊.

@rrousselGit
Copy link
Owner

Cool! I'll leave this open for a few more days, but it should be good to go.
I just need to try it locally and evaluate the possible danger of this final change to be safe :)

Copy link

coderabbitai bot commented Nov 3, 2024

Walkthrough

This pull request refines sealed class handling in the Freezed package. The Concrete class template now conditionally prepends the final keyword based on the data.isSealed property, and the Class model has been extended with a new required isSealed parameter. Additionally, new test and integration files have been added. The tests verify that pattern matching with sealed classes, whether with single or multiple constructors, properly emits a warning for unmatched patterns.

Changes

File(s) Change Summary
packages/freezed/lib/.../concrete_template.dart
packages/freezed/lib/.../models.dart
Added conditional final keyword in the Concrete class when data.isSealed is true, and introduced a new required isSealed parameter (with corresponding field) in the Class model.
packages/freezed/test/.../finalized_test.dart
packages/freezed/test/.../finalized.dart
Introduced new test and integration files. The test file verifies that switch statements on sealed classes trigger the PATTERN_NEVER_MATCHES_VALUE_TYPE warning, and the integration file defines sealed classes (FinalizedFoo, FinalizedBar, and FinalizedMultiple) using factory constructors.

Sequence Diagram(s)

sequenceDiagram
    participant TestRunner
    participant SealedClass
    participant Analyzer
    TestRunner->>SealedClass: Instantiate sealed class (e.g., FinalizedFoo)
    TestRunner->>Analyzer: Execute switch pattern check on instance
    Analyzer-->>TestRunner: Return PATTERN_NEVER_MATCHES_VALUE_TYPE warning
Loading

Possibly related PRs

  • Private unions #1159: Addresses modifications to sealed class mutability by handling final keyword and sealed properties in class constructors.
  • External factory #1158: Introduces breaking changes for sealed classes, ensuring they are either abstract, sealed, or manually implemented, which relates closely to the changes in handling final and sealed classes.

Poem

I'm a hopping rabbit in the code forest today,
Sealed classes now shine with a "final" display.
Factories and tests dance in a rhythmic beat,
Patterns refined make the warnings complete.
With every carrot of code, I cheer and say, "Hooray!"
🐰✨


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 47f79b8 and c7dcdbb.

📒 Files selected for processing (4)
  • packages/freezed/lib/src/models.dart (3 hunks)
  • packages/freezed/lib/src/templates/concrete_template.dart (1 hunks)
  • packages/freezed/test/finalized_test.dart (1 hunks)
  • packages/freezed/test/integration/finalized.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/freezed/lib/src/templates/concrete_template.dart
  • packages/freezed/test/integration/finalized.dart
🔇 Additional comments (6)
packages/freezed/lib/src/models.dart (3)

497-508: New required parameter added correctly.

The addition of the required isSealed parameter to the Class constructor is well-implemented and properly integrated with the existing parameters.


520-520: Field declaration looks good.

The isSealed field is correctly declared as a final boolean, matching the parameter's type.


602-602: Property initialization properly implemented.

The isSealed property is correctly initialized from declaration.declaredElement!.isSealed, accessing the sealed status of the Dart class.

packages/freezed/test/finalized_test.dart (3)

1-5: Imports look good.

All necessary imports for analyzer, error handling, build_test, and testing are present.


6-43: Well-structured test for single constructor sealed classes.

The test properly validates that the Dart analyzer emits a PATTERN_NEVER_MATCHES_VALUE_TYPE warning when attempting to match a pattern that can never match due to the class being sealed. The test setup, execution, and verification flow is excellent.


44-85: Comprehensive test for multiple constructors scenario.

This test group effectively verifies that the sealed class behavior works correctly with multiple constructors. It ensures that the Dart analyzer properly identifies patterns that can never match and emits the appropriate warning.


Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Outside diff range and nitpick comments (4)
packages/freezed/test/integration/finalized.dart (2)

10-13: Consider consolidating similar test cases

This class is structurally identical to FinalizedFoo. Consider whether having two identical test cases adds value, or if the test coverage could be achieved with a single class.


15-20: LGTM: Comprehensive test case for pattern matching

This class provides excellent coverage for testing pattern matching with multiple constructors, which is crucial for validating the compile-time warnings mentioned in the PR objectives.

Consider adding test cases for:

  1. Nested sealed classes
  2. Generic sealed classes
  3. Sealed classes with complex property types

These additional cases would help ensure the feature works correctly in more complex scenarios.

packages/freezed/test/finalized_test.dart (2)

8-42: Consider enhancing test coverage for single constructor case.

The test effectively verifies the warning for impossible pattern matches. Consider these enhancements:

  1. Add a positive test case to verify that valid pattern matches don't trigger warnings
  2. Verify the error message content for better diagnostic coverage
  3. Assert the error location (line/column) to ensure precise diagnostic reporting

Example enhancement:

 test('causes pattern_never_matches_value_type warning when trying to match on pattern that can never match',
   () async {
     final main = await resolveSources(
       {
         'freezed|test/integration/main.dart': r'''
           library main;
           import 'finalized.dart';

           void main() {
             switch (FinalizedFoo()) {
               case FinalizedBar():
                 break;
               case FinalizedFoo():
                 break;
             }
           }
           ''',
       },
       (r) => r.findLibraryByName('main'),
     );

     final errorResult = await main!.session
         .getErrors('/freezed/test/integration/main.dart') as ErrorsResult;

     expect(errorResult.errors, hasLength(1));

     final [error] = errorResult.errors;

     expect(error.errorCode.errorSeverity, ErrorSeverity.WARNING);
     expect(error.errorCode.name, 'PATTERN_NEVER_MATCHES_VALUE_TYPE');
+    expect(error.message, contains('pattern can never match'));
+    expect(error.location.lineNumber, 10);
+    expect(error.location.columnNumber, 10);
   });

+  test('does not warn for valid pattern matches', () async {
+    final main = await resolveSources(
+      {
+        'freezed|test/integration/main.dart': r'''
+          library main;
+          import 'finalized.dart';
+
+          void main() {
+            switch (FinalizedFoo()) {
+              case FinalizedFoo():
+                break;
+            }
+          }
+          ''',
+      },
+      (r) => r.findLibraryByName('main'),
+    );
+
+    final errorResult = await main!.session
+        .getErrors('/freezed/test/integration/main.dart') as ErrorsResult;
+
+    expect(errorResult.errors, isEmpty);
+  });

44-85: Consider refactoring multiple constructors test for better maintainability.

While the test effectively verifies the core functionality, consider these improvements:

  1. Split into separate test cases for each constructor variant
  2. Extract common error verification logic into a test utility
  3. Add positive test cases for valid pattern matches

Example refactoring:

+Future<void> expectPatternMatchWarning(String source) async {
+  final main = await resolveSources(
+    {'freezed|test/integration/main.dart': source},
+    (r) => r.findLibraryByName('main'),
+  );
+
+  final errorResult = await main!.session
+      .getErrors('/freezed/test/integration/main.dart') as ErrorsResult;
+
+  expect(errorResult.errors, hasLength(1));
+  final [error] = errorResult.errors;
+  expect(error.errorCode.errorSeverity, ErrorSeverity.WARNING);
+  expect(error.errorCode.name, 'PATTERN_NEVER_MATCHES_VALUE_TYPE');
+}

 group('multiple constructors', () {
-  test('causes pattern_never_matches_value_type warning when trying to match on pattern that can never match',
+  test('warns when matching FinalizedMultiple.b against FinalizedBar',
     () async {
-      final main = await resolveSources(
-        {
-          'freezed|test/integration/main.dart': r'''
+      await expectPatternMatchWarning(r'''
           library main;
           import 'finalized.dart';

           void main() {
             switch (FinalizedMultiple.b()) {
               case FinalizedBar():
                 break;
             }
           }
-          ''',
-        },
-        (r) => r.findLibraryByName('main'),
-      );
+          ''');
+    });

+  test('warns when matching FinalizedMultiple.b against FinalizedMultipleA',
+    () async {
+      await expectPatternMatchWarning(r'''
+          library main;
+          import 'finalized.dart';
+
+          void main() {
+            switch (FinalizedMultiple.b()) {
+              case FinalizedMultipleA():
+                break;
+            }
+          }
+          ''');
+    });

+  test('allows matching FinalizedMultiple.b against FinalizedMultipleB',
+    () async {
+      final main = await resolveSources(
+        {
+          'freezed|test/integration/main.dart': r'''
+            library main;
+            import 'finalized.dart';
+
+            void main() {
+              switch (FinalizedMultiple.b()) {
+                case FinalizedMultipleB():
+                  break;
+              }
+            }
+            ''',
+        },
+        (r) => r.findLibraryByName('main'),
+      );
+
+      final errorResult = await main!.session
+          .getErrors('/freezed/test/integration/main.dart') as ErrorsResult;
+
+      expect(errorResult.errors, isEmpty);
+    });
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between 382592c and 47f79b8.

📒 Files selected for processing (4)
  • packages/freezed/lib/src/templates/concrete_template.dart (2 hunks)
  • packages/freezed/pubspec_overrides.yaml (1 hunks)
  • packages/freezed/test/finalized_test.dart (1 hunks)
  • packages/freezed/test/integration/finalized.dart (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • packages/freezed/pubspec_overrides.yaml
🔇 Additional comments (4)
packages/freezed/test/integration/finalized.dart (2)

1-3: LGTM: Proper Freezed setup

The import and part directive follow the standard Freezed setup pattern.


5-8: LGTM: Simple sealed class test case

This provides a good test case for the simplest possible sealed class scenario with a single constructor.

Let's verify the test coverage for this class:

packages/freezed/test/finalized_test.dart (1)

1-7: LGTM! Well-structured test setup.

The imports and main test group structure are appropriate for testing the sealed/final class behavior.

packages/freezed/lib/src/templates/concrete_template.dart (1)

67-67: ⚠️ Potential issue

Update minimum Dart SDK constraint due to use of final class and sealed class

The use of final class (line 67) and sealed class (line 89) requires Dart SDK version 2.17.0 or higher. Please update the minimum SDK version in your pubspec.yaml file to >=2.17.0 to ensure compatibility and prevent build errors for users on earlier SDK versions.

Run the following script to verify the current SDK constraints:

Also applies to: 89-89

@rrousselGit
Copy link
Owner

My appologies, I kind of forgot about this PR. Let me see what we can do here.
Feel free to ping me if I forget again.

@Yegair
Copy link
Author

Yegair commented Jan 6, 2025

Friendly Reminder to take a look at this PR 😊

If there is a way I can help testing it, just let me know!

@EArminjon
Copy link

This is valuable.

Although I'm wondering whether we need a flag at all. Why not always make the generated class final if it helps? Extending a Freezed class isn't supported anyway, as they only have factory constructors.

Is this PR stil relevant as now freezed support "extends" ?

@Yegair
Copy link
Author

Yegair commented Feb 27, 2025

Is this PR stil relevant as now freezed support "extends" ?

At least the original way how it was implemented will no longer work. Just did a quick rebase from upstream, but have to write quite a few more tests to make sure it works in combination with the new features from release 3.x.x.

Will take a closer look on the weekend!

@rrousselGit
Copy link
Owner

Sorry as you can see, I got sidetracked.
I'm not sure how relevant it is anymore. To be honest, looking back at the motivation, I wonder if the iDE isn't to blame instead.

I'm not sure whether changing the generated code makes sense, when analyzer likely should know already that the case doesn't make sense for a sealed class.

After-all, sealed classes are some form of final classes

@rrousselGit
Copy link
Owner

Actually ignore me, I can see why the analyzer works this way.

if you don't mind fixing the conflicts, we can merge this.

For now, let's not enable it by default though. I'd rather not make a 4.0 right after a 3.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants