Skip to content
This repository was archived by the owner on Mar 27, 2026. It is now read-only.
This repository was archived by the owner on Mar 27, 2026. It is now read-only.

Union of Modddels: how to properly handle narrowed types? #7

Closed

Description

@chikamichi

Sorry for the continuous streak of questions, I got hyped!

Using my repo from #6 as context, I now face a rather annoying issue regarding (I guess) ahead of compilation vs runtime types, when a modddel is exposed through a Riverpod provider cased Modddels in a union (in my case, the union modddels is exposed through a Riverpod AsyncNotifier, which wraps an AsyncValue<MapLayer> with MapLayer being the union of modddels: MapLayer.raster, MapLayer.vector and MapLayer.geojson).

My guess is that I’m not providing Riverpod with enough insight, but I did not manage to discover how. edit: wrong guessing 🙈


Here it is, explained by comments:

// [… imports … ]

class Map extends ConsumerWidget {
  const Map({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final logger = ref.read(loggerProvider);

    final provider = mapLayerControllerProvider(layerName: "some_raster_layer");

    final rasterLayer = ref.watch(provider);

    return rasterLayer.when(data: (layer) {
      logger.d(layer);
      // ==> layer exposed as a ValidRaster, which is expected, for it was created
      // with MapLayer.raster(…) with valid attributes values;
      // but this fails because Dart thinks it’s a MapLayer, which has no position attribute:
      // logger.i(layer.position.value);
      // ==> Hence doing a cumbersome type casting:
      final l = layer as ValidRaster;
      logger.i(l.position.value); // works fine now!
      return Text("OK");
    }, error: (e, _) {
      logger.d(e);
      return Text("KO");
    }, loading: () {
      logger.d("Loading…");
      return Text("Loading…");
    });
  }
}

Of course, the problem stays the same if I, say, mapValidity on layer within the widget (rather than within the controller/provider):

    return rasterLayer.when(data: (layer) {
      return layer.mapValidity(valid: (layer) {
        logger.d(layer.runtimeType); // ValidRaster
        // logger.i(layer.position.value); // Error: The getter 'position' isn’t defined for the type 'ValidMapLayer'.
        logger.i((layer as ValidRaster).position.value); // works fine, for ValidRaster extends MapLayer
                                                         // with Raster, which has the attributes
        return Text("Valid layer $layer");
      }, invalid: (layer) {
        logger.d(layer.runtimeType); // InvalidRaster
        logger.d(layer.failures); // works fine; layer is seen as an InvalidMapLayer, though
        return Text("Invalid layer $layer");
      });
    }, error: (e, _) {
      logger.d(e);
      return Text("KO");
    }, loading: () {
      logger.d("Loading…");
      return Text("Loading…");
    });

Activity

  1. CodingSoot commented on Jun 1, 2023

    @CodingSoot
    Owner

    No worries at all ! I appreciate your active participation and questions.

    You (almost) never need to use type casting when using modddels. The package has been made to provide compile-time safety, and type casting breaks that.

    Let me put your modddel here for reference. It's a union MapLayer with three case-modddels : Raster, Vector and Geojson.

    @Modddel(validationSteps: [
      ValidationStep([contentValidation])
    ])
    class MapLayer extends SimpleEntity<InvalidMapLayer, ValidMapLayer>
        with _$MapLayer {
      MapLayer._();
    
      factory MapLayer.raster({
        required MapLayerPosition position,
        //...
      }) {
        return _$MapLayer._createRaster(
          position: position,
          //...
        );
      }
    
      factory MapLayer.vector({
        required MapLayerPosition position,
        // ...
      }) {
        return _$MapLayer._createVector(
          position: position,
        );
      }
    
      factory MapLayer.geojson({
        required MapLayerPosition position,
        //...
      }) {
        return _$MapLayer._createGeojson(
          position: position,
          // ...
        );
      }
    }

    There are two parts to your question.

    First, creating a modddel with valid arguments doesn't mean that it will have a valid type compile-time. This is simply impossible as you can't predict or know the value of an object compile-time (unless it's a constant). What it does though is allowing you to deal with all validation states in a compile-safe way.

    So for example, if you have a Raster modddel, you can do this :

    void myFunction(Raster raster) {
      // Pattern matching between the different validation states
      raster.map(
        valid: (valid) {
          // `valid` is of type `ValidRaster`
        },
        invalidMid: (invalidMid) {
          // `invalidMid` is of type `InvalidRasterMid` which extends `InvalidRaster`
        },
      );
    
      // Or if you only need to handle the "valid" case :
      raster.mapOrNull(
        valid: (valid) {}, // `valid` is of type `ValidRaster`
      );
    }

    We need pattern matching because we don't know if the raster that myFunction receives is valid or not. On the other hand, if myFunction requires receiving a ValidRaster, you can easily enforce it :

    void myFunction(ValidRaster validRaster) { //....

    So you'll need to do pattern matching somewhere before calling myFunction.

    Secondly, just like for freezed, when you create an instance of a case-modddel (for example : MapLayer.raster(…) ), its type is broadened to the type of the union ( MapLayer ). That's to be expected because after all, it's a simple factory constructor. So you'll need to do "modddels pattern matching" in order to get to your Raster type from MapLayer.

    void myOtherFunction(MapLayer mapLayer) {
      mapLayer.mapMapLayer(
        raster: (raster) {}, // Of type `Raster`
        vector: (vector) {}, // Of type `Vector`
        geojson: (geojson) {}, // Of type `Geojson`
      );
      // Or if you only need to handle the `Raster` case :
      mapLayer.mapOrNullMapLayer(
        raster: (raster) {},
      );
    }

    Same principle applies here, you can require that myOtherFunction receives a specific case-modddel (Raster, Vector or Geojson).

    In your example :

     return layer.mapValidity(valid: (layer) {
            logger.d(layer.runtimeType); // ValidRaster
            // logger.i(layer.position.value); // Error: The getter 'position' isn’t defined for the type 'ValidMapLayer'.

    The reason you get an error is that in the beginning layer is of type MapLayer, and after calling mapValidity, in the valid callback, it is of type ValidMapLayer, which doesn't contain the property position because it's not a shared property. So you'll need to do some "modddels pattern matching" in order to get to ValidRaster and access the position property. A feature I personally like is that you can mix "validity" pattern matching with "modddels pattern matching" in any order, so for example you can move from MapLayer to ValidMapLayer to ValidRaster, or move from MapLayer to Raster to ValidRaster.

    To get back to your specific usecase, it depends on what you need. Does your provider only hold Raster or other types of layers too ? Does it hold only valid instances ? And in your widget, do you need to handle all case-modddels / validation states ?

    Depending on your answer, you can make the type of the provider exactly what you need it to be, and also handle in the widget only the needed cases (consider using maybeMap or mapOrNull if you don't need to handle all validation states for example).

  2. CodingSoot commented on Jun 1, 2023

    @CodingSoot
    Owner

    On this note, freezed supports directly creating a union-case and preserving its type, for example :

    @freezed
    class MapLayer with _$MapLayer {
      const factory MapLayer.raster(int someParam) = Raster;
    
      const factory MapLayer.vector(int someParam) = Vector;
    
      const factory MapLayer.geojson(int someParam) = Geojson;
    }
    
    // then, instead of calling `MapLayer.raster(1)` :
    final Raster raster = Raster(1);

    In modddels, this is not supported (for now). If needed, you can cast the case-modddel directly after creating it, like this :

    final raster = MapLayer.raster(
      //...
    ) as Raster;

    Although it's pretty safe, it's not very clean so I'll look into implementing a better alternative.

  3. chikamichi commented on Jun 1, 2023

    @chikamichi
    Author

    Thank you sir! I'll try that asap.

  4. chikamichi commented on Jun 1, 2023

    @chikamichi
    Author

    Alright, you made it crystal clear what to do, and sure it unfolds naturally:

    class Map extends ConsumerWidget {
      const Map({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final logger = ref.read(loggerProvider);
    
        final provider = mapLayerControllerProvider(layerName: "some_raster_layer");
        final someLayer = ref.watch(provider);
        // TODO: load other map layers as well (as per current / user config), iterate on collection, etc.
    
        return someLayer.when(data: (mapLayer) {
          // here, I first mapped over cased-modddels types, then validity, but one could do it the reverse way
          return mapLayer.mapMapLayer(
              raster: (rasterLayer) => rasterLayer.mapValidity(
                  valid: (validRasterLayer) => Text(
                      "Valid raster layer! ${validRasterLayer.position.value}"),
                  invalid: (invalidRasterLayer) =>
                      Text("Invalid raster layer! ${invalidRasterLayer.failures}")),
              vector: (vectorLayer) => Text("Vector layer! TODO: mapValidity"),
              geojson: (geojsonLayer) => Text("GeoJSON layer! TODO: mapValidity"));
        }, error: (e, _) {
          logger.d(e);
          return Text("KO");
        }, loading: () {
          logger.d("Loading…");
          return Text("Loading…");
        });
      }
    }

    What I was mostly missing is the existence of mapMapLayer (ie. the fact that a unioned Modddel generates such a map[ModddelName] method to map over its case-modddels, narrowing types down in the process). I know that’s properly documented, though it didn’t "click" when I read the page. Now it does, and the table mapping between Super-sealed class and Case-Modddels makes perfect sense!

    Now it’s just a matter of organizing widget’s controllers, and eventually services/repositories, so as to keep the widgets light and only concerned with displaying either the expected content or a user-friendly error.

    Thank you again!

  5. changed the title [-]Modddels + Riverpod: how to properly handle narrowed type casting?[/-] [+]Union of Modddels: how to properly handle narrowed type casting?[/+] on Jun 1, 2023
  6. changed the title [-]Union of Modddels: how to properly handle narrowed type casting?[/-] [+]Union of Modddels: how to properly handle narrowed types?[/+] on Jun 1, 2023
  7. CodingSoot commented on Jun 1, 2023

    @CodingSoot
    Owner

    Awesome, glad to hear you're getting the hang of it !

    Now it’s just a matter of organizing widget’s controllers, and eventually services/repositories, so as to keep the widgets light and only concerned with displaying either the expected content or a user-friendly error.

    That's exactly the kind of smooth usage I was aiming for with modddels. I hope you enjoy using the package, and feel free to reach out if you run into any more questions or issues.

  8. Repository owner locked and limited conversation to collaborators on Jun 1, 2023
  9. converted this issue into a discussion #8 on Jun 1, 2023
  10. added
    discussionOpen-ended topics or questions, likely to be converted to a Github Discussion
    on Jun 1, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    discussionOpen-ended topics or questions, likely to be converted to a Github Discussion

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions