Skip to content

Crash type casting issues in Dart related to generic values and null checks, type '(String?) => void' is not a subtype of type '((dynamic) => void)?' #60288

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

Closed
MikePendo opened this issue Mar 9, 2025 · 10 comments

Comments

@MikePendo
Copy link

HI
Dart Info:

- Dart 3.6.0 (stable) (Thu Dec 5 07:46:24 2024 -0800) on "macos_arm64"
- on macos / Version 15.3.1 (Build 24D70)

Original question posted here StackOverflow

I am copying paste it here for better clarity:
I am trying to figure out if widget.onChanged of RadioListTile is not null, but it seems like dart is very strict when it comes to generics, any suggestion on how I can check it?
(I am a little new to Fluter/Dart )

void testWidgetOnNUllDynamic(dynamic clbk) {
      if (clbk != null) {
        print('not null');
      }
    }
    
    void testWidgetOnNUllCallback(Widget widget) {
      if (widget is RadioListTile) {
        // if (widget.onChanged != null) {
        //   //type '(String?) => void' is not a subtype of type '((dynamic) => void)?'
        //   print('Crash here');
        // }
        //attempt to erase the type
        testWidgetOnNUllDynamic(widget.onChanged);
      }
    }

//This method is redundant 
    void testIfOnChangeIsNull(Widget widget) {
      testWidgetOnNUllCallback(widget);
    }

    test('when widget is RadioListTile onChange test', () {
// <String> - Can be anything 
      RadioListTile<String> widget = RadioListTile(value: 'value', activeColor: 0, toggleable: true, onChanged:(value) => debugPrint('Test'), groupValue: null,);
       testIfOnChangeIsNull(widget);
      
    });

I think that I understand the error I am not sure how to avoid it OR how to check of nallability without casting it to concrete type
Thanks

@RohitSaily
Copy link
Contributor

My understanding is that widget.onChanged is of type void Function(String?) i.e. it is a function which takes a nullable string as input. The function itself is not nullable therefore the error is occurring due to a null check on a non-nullable object.

If you intend to allow the function to be nullable, it should be of type void Function(String?)? (note the second ? in the type). If this does not help, please post a minimal example showcasing the same error and I'll try to take another look at it.

@MikePendo
Copy link
Author

@RohitSaily
onChanged can be null.
I receive the widget inside the plugin The void Function(<can be anything>?).
So I want to understand if the onChange is null.
I posted a simple example as part of the tests to demonstrate the issue,
Let me know if its enough
Thanks

@RohitSaily
Copy link
Contributor

RohitSaily commented Mar 9, 2025

For anyone working on this, I was able to create a complete and small Flutter project for this:

  1. Do flutter create a

  2. pubspec.yaml:

name: 'a'
publish_to: 'none'
version: '1.0.0+1'
environment:
      sdk: ^3.8.0-149.0.dev
dependencies:
      flutter:
            sdk: 'flutter'
  1. main.dart:
import 'package:flutter/material.dart';
void main()
{	final Widget tile=RadioListTile
	(	value: 'value',
		activeColor: Colors.red,
		toggleable: true,
		onChanged:(value)=>debugPrint('Test'),
		groupValue: null
	);
	if (tile is RadioListTile)
	{	print(tile.onChanged);//Access of "onChanged" causes the crash
	}
}

@MikePendo I am able to reproduce the crash on macOS. I am still working to figure it out now

@RohitSaily
Copy link
Contributor

RohitSaily commented Mar 9, 2025

The root of the problem is that the input parameter cannot be generalized. If a function operates on a specialized type, it may access specialized fields. Therefore the input type cannot be erased.

I believe for dynamic this is a bug or, at best, a nonintuitive design of the type system. dynamic is supposed to allow the computation to be attempted at runtime, and so I believe it should be an exclusion to this rule. The computation can be performed as long as the object at runtime provides the API used by the computation.

The best solution I could come up with so far is to perform the check with the type i.e. widget is RadioListTile<String>. If you need a generic solution, you can try propagating the generic information through type parameters e.g.

void main()
{	final Widget tile=RadioListTile
	(	value: 'value',
		activeColor: Colors.red,
		toggleable: true,
		onChanged:(value)=>debugPrint('Test'),
		groupValue: null
	);
        f<String>();
}
void f<T>(final Widget widget)
{      if (tile is RadioListTile<T>)
	{	print(tile.onChanged);//Should work as long as T is correct.
	}
}

This is not ideal, as T must be specified manually for each call. But it is the current best generic solution I could come up with.

@MikePendo
Copy link
Author

MikePendo commented Mar 9, 2025

Currently we do something similar:

void getRadioListTileInfo(RadioListTile widget) {
    _isContainer = true;
    dynamic convertedWidget;

    switch(widget.runtimeType) {
      case const (RadioListTile<num>):
      convertedWidget = widget as RadioListTile<num>;
      _onChanged = convertedWidget.onChanged?.toString();
      _isClickable = (convertedWidget as RadioListTile<num>).isCallbackClickExists();
      case const (RadioListTile<int>):
      case const (RadioListTile<double>):
etc ..

We dont have the T of the widget and whenever that T is something app specific (please note the above code is from the package) we are kinda crashing?
So I was wondering maybe there is some way to somehow convert that onChange to something different (dynamic didnt work) that will erase the type and only will indicate if the object itself is not null (maybe valid memory address)?

@RohitSaily
Copy link
Contributor

RohitSaily commented Mar 9, 2025

I was able to get it working by casting the widget to dynamic before accessing onChange. For example

import 'package:flutter/material.dart';
void main()
{	final Widget tile=RadioListTile
	(	value: 'value',
		activeColor: Colors.red,
		toggleable: true,
		onChanged:(value)=>debugPrint('Test'),
		groupValue: null
	);
	if (tile is RadioListTile)
	{	print((tile as dynamic).onChanged==null);//No crash :)
	}
}

dynamic disables static type checking so I would only use it for the specific access it is necessary for as done above. By doing so, the rest of the code can be kept type-safe. To reiterate, anything done to the object when it is casted as dynamic is not checked at compile-time, so write and test the code carefully! If any similar issues are encountered, a dynamic cast is probably required.

@MikePendo
Copy link
Author

@RohitSaily
Thanks! that did the trick. in my initial example I was trying to overcome it by passing the onChange as dynamic parameter to testWidgetOnNUllDynamic but that didnt do the trick

void testWidgetOnNUllDynamic(dynamic clbk) {
      if (clbk != null) {
        print('still crashing');
      }
    }
 void testWidgetOnNUllCallback(Widget widget) {
      if (widget is RadioListTile) {
        if ((widget as dynamic).onChanged != null) {
          //type '(String?) => void' is not a subtype of type '((dynamic) => void)?'
          print('wont crash any more');
        }

        //attempt to erase the type
        testWidgetOnNUllDynamic((widget as dynamic).onChanged);
        //attempt to erase the type will crash 
         testWidgetOnNUllDynamic(widget.onChanged);
      }
    }

And although the testWidgetOnNUllDynamic(dynamic clbk) received dynamic it still crashed a little strange imho. I tried to make it more readable and then reuse for other similar cases BUT that has a crash.
It seems like if you cast any object to dynamic you can invoke any method without compiler error (interesting)

@RohitSaily please feel free to close the issue, Thanks again

@RohitSaily
Copy link
Contributor

RohitSaily commented Mar 9, 2025

@MikePendo happy to help! I am just another member of this community and do not have permissions to perform any management within this repository. I believe you will have to close this issue or an official maintainer will.

Also I believe the type erasing method was not working because the onChange was being type-erased but it was the widget which needed to be type-erased first, since accessing onChange is not possible with types due to the incompatible types.

@mraleph
Copy link
Member

mraleph commented Mar 10, 2025

There are few things which are important to understand here.

First is that we want to be able to trust function types. If you have X Function(Y) then you should be able to pass an subtype of Y to it without any additional checking and what you get back is guaranteed to be a subtype of X.

Second, function types are contravariant with respect to their parameter types. To put it in simple terms1: if you have a function that accepts an Animal you can safely use it in a place which passes Dog. But if you have a function that accepts Dogs you can't use it in the code which passes Animals (because it might be some non-Dog animal):

abstract class Animal {}
class Dog extends Animal {
}
class Cat extends Animal {
}

void handleADog(Dog d) { }

void handleAnAnimal(Animal a) {}

void processAnimals(void Function(Animal) f) {
  f(Cat());
}

void processDogs(void Function(Dog) f) {
  f(Dog());
}

processDogs(handleADog); // ok: handleADog can handle any Dog
processDogs(handleAnAnimal); // ok: handleAnAnimal can handle any animal, which means any Dog.
processAnimals(handleADog); // not ok: handleADog can handle any Dog, but not any Animal.

The dynamic type does not have any special behavior here. It's just a supertype of all other types: which means void Function(Dog) is supertype of void Function(dynamic): you can use a function that accepts dynamic to handle values of any type. The only place where it disables any sort of checking is when you have an expression of static type dynamic: here Dart allows you to invoke anything on a dynamic - and defers resolution of invocation (and associated checking to runtime).

Finally, function type contravariance makes interaction between Dart's generics (which are unsoundly covariant) awkward: consider the following code:

class A<T> {
  void Function(T) f;
  A(this.f);
}

final a = A<Dog>(handleADog);
final b = a as A<Animal>;
final f = b.f;
f(Cat());

Dart's subtyping rules say that A<Dog> is subtype of A<Animal>. Which makes cast a as A<Animal> succeed.

However we hit a challenge with b.f - static type of b.f is void Function(Animal) which means function f(Cat()) is statically valid. This code looks correctly typed - but we know there is a hidden issue here: b.f is just a.f and it is void Function(Dog) which can't handle a Cat. How do we deal with this?

What Dart does here is the following: in places like this (where covariant type parameter occurs in a contravariant position) it inserts a check when you access field f to make sure that its static type and runtime type are in agreement. Effectively this code looks like this:

final f = b.f as void Function(Animal);  // as ... inserted by compiler

This will cause b.f access to throw.

What can you do to avoid throw? Well, the usual trick is: if you know that A is going to be used covariantly then you need to confine uses of f inside the class so that it is never accessed covariantly f.

class A<T> {
  void Function(T)? f;
  A(this.f);

  bool get hasF => f != null;
}

final a = A<Dog>(handleADog);
final b = a as A<Animal>;
final hasF = b.f == null;  // will throw because it is compiled as `(b.f as void Function(Animal)) == null`
final hasF2 = b.hasF;  // ok.

Footnotes

  1. using Liskov substitution principle which makes subtyping relation intuitive

@eernstg
Copy link
Member

eernstg commented Mar 10, 2025

Agreeing with everything @mraleph said, I'd like to add a couple of extra remarks:

If X is a type variable declared by a class, and the class has an instance variable f of type void Function(X) then we have exactly the kind of situation which is the target of dart-lang/language#296, also known as a "non-covariant instance variable". A similar issue comes up if the return type of a method has a similar occurrence of a type parameter of the class, so we may talk about "non-covariant members" and deal with them in similar ways. For example:

class A<X> {
  void Function(X) f;
  A(this.f);
  void Function(X) method() => (X x) {};
}

void main() {
  A<num> a = A<int>((int i) {});
  a.f; // Throws!
  a.method(); // Throws!
}

You can enable the lint unsafe_variance in order to detect this kind of situation.

@mraleph mentioned that one remedy is to ensure that the non-covariant member is never accessed on any other receiver than this (note that violations of this rule will not give rise to compile-time errors or even warnings). The documentation of the lint mentions this technique, as well as two other techniques.

Another way to deal with this kind of run-time failure is to change the subtype relationship such that the unsafe situation can never occur. Statically checked variance is a proposal which will allow us to declare that a particular type variable is invariant rather than covariant (today, every type variable in Dart which is declared by a class is covariant, and soundness is maintained by means of run-time type checks, as with a.f above).

When a type parameter of a class A is invariant then there is no subtype relationship among different generic instantiations of that class. In other words A<T> and A<S> are just two unrelated types when T is not the same type as S. In contrast, if the type parameter is covariant and T is a subtype of S then A<T> is also a subtype of A<S> (the types "co-vary", hence the term 'covariant').

If we assume that statically checked variance is available then we can do this:

// Use statically checked variance to declare `X` as invariant: `inout X`.
class A<inout X> {
  void Function(X) f;
  A(this.f);
  void Function(X) method() => (X x) {};
}

void dangerousSituationIsNowAnError() {
  A<num> a = A<int>((int i) {}); // Compile-time error.
  a.f; // Irrelevant, the compile-time error prevents this situation.
}

void correctedSituationWhichIsNotDangerous() {
  A<int> a2 = A<int>((int i) {}); // This is OK!
  a2.f; // Safe.
  a2.method(); // Safe.
}

By the way, votes (👍) for statically checked variance are highly appreciated! When features are prioritized, community support does matter.

To put the original example into this context: The reason why there is a run-time type error is that widget is RadioListTile promotes widget to RadioListTile which means RadioListTile<dynamic>, but it is actually a RadioListTile<String>. This means that we have a covariant typing of the radio list tile (the actual type argument is String, but the statically known type argument is dynamic). So we get the run-time failure when the non-covariant instance variable is accessed.

This is also the reason why there is no run-time failure if we promote widget to RadioListType<String>. @RohitSaily already mentioned that we could pass the actual type argument along as a separate type parameter to the function that performs the promotion. However, that's extra work, and nothing will stop us if we make a mistake and pass a supertype of the actual type argument:

import 'package:flutter/material.dart';

void main() {
  final tile = RadioListTile<String>(
    value: 'value',
    activeColor: Colors.red,
    toggleable: true,
    onChanged: (value) => debugPrint('Test'),
    groupValue: null,
  );
  f<Object>(tile); // Oops, should have been `String`!
}

void f<T>(final Widget tile) {
  if (tile is RadioListTile<T>) {
    print(tile.onChanged); // Throws
  }
}

One of the techniques that you can use if you wish to access a non-covariant member is to make the access untyped. That is, cast the receiver to dynamic:

import 'package:flutter/material.dart';

// ignore_for_file: unused_local_variable

void main() {
  final tile = RadioListTile<String>(
    value: 'value',
    activeColor: Colors.red,
    toggleable: true,
    onChanged: (value) => debugPrint('Test'),
    groupValue: null,
  );
  f(tile);
}

void f(final Widget tile) {
  if (tile is RadioListTile) {
    print((tile as dynamic).onChanged); // Does not throw.
  }
}

This means that there will not be a covariance-related type check at run time, but in return for avoiding that you only have the type dynamic for the resulting function object.

You may then need to perform other checks (e.g., final fun = (tile as dynamic).onChanged and then if (fun is void Function(String?)) ...).

This means that you will have to carry more of the type safety burden manually, but it may be convenient if you'd otherwise have to perform a major refactoring in order to enforce that RadioListTile is only accessed invariantly, or whatever is required with your chosen approach.

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

No branches or pull requests

4 participants