Skip to content

Inconsistent Generic Type Inference Leads to Runtime Type Error #60742

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
xeinebiu opened this issue May 16, 2025 · 5 comments
Closed

Inconsistent Generic Type Inference Leads to Runtime Type Error #60742

xeinebiu opened this issue May 16, 2025 · 5 comments

Comments

@xeinebiu
Copy link

xeinebiu commented May 16, 2025

Description

When executing the following Dart code:

import 'dart:developer';

class KLineChartData<T> {
  const KLineChartData({
    required this.items,
    required this.itemToValue,
  });

  final List<T> items;
  final KChartItemToValueCallback<T> itemToValue;
}

typedef KChartItemToValueCallback<T> = double Function(T item);

class KLineChart<T> {
  final KLineChartData<T> data;

  const KLineChart({required this.data});

  void print() {
    final values = data.items.map((item) => data.itemToValue(item)).toList();
    inspect(values);
  }
}

class Helper {
  Helper._();

  static KLineChart create() {
    final data = [1, 2, 3, 4, 5, 6, 7];

    final chartData = KLineChartData(
      items: data,
      itemToValue: (item) => item.toDouble(),
    );

    return KLineChart(data: chartData);
  }
}

void main() {
  final chart = Helper.create();
  chart.print();
}

We get the following runtime error:

Unhandled exception:
type '(int) => double' is not a subtype of type '(dynamic) => double'

Root Cause

The issue is due to generic type inference. In the create() method, no explicit generic type is provided for KLineChart, so Dart infers dynamic:

static KLineChart create() { // T is inferred as dynamic

As a result, the type of itemToValue becomes double Function(dynamic) at runtime, which leads to a type mismatch with the actual (int) => double function.

⚠️ This is especially problematic because the Dart compiler does not produce any warnings or errors during compilation — the failure only happens at runtime.


Inconsistent Behavior Observed

Interestingly, the code behaves correctly in the following variations, even though the type is still not explicitly declared at the function signature:

1. Assigning to a local variable before returning:

static KLineChart create() {
  final data = [1, 2, 3, 4, 5, 6, 7];

  final chartData = KLineChartData(
    items: data,
    itemToValue: (item) => item.toDouble(),
  );

  final chart = KLineChart(data: chartData); // works
  return chart;
}

2. Explicitly specifying the generic type at construction:

static KLineChart create() {
  final data = [1, 2, 3, 4, 5, 6, 7];

  final chartData = KLineChartData(
    items: data,
    itemToValue: (item) => item.toDouble(),
  );

  return KLineChart<int>(data: chartData); // works
}

Expected Behavior

The compiler should either:

  • Produce a compile-time error or warning when type inference results in a dynamic that later causes a function signature mismatch, or
  • Consistently infer types to avoid such pitfalls.

Why This Is a Problem

This behavior introduces a dangerous inconsistency:

  • The code may appear type-safe and pass compilation.
  • Runtime failures occur due to subtle issues with generic type inference.
  • Developers may struggle to debug such issues, especially when the code seems logically correct.

Suggested Fix or Feature

Improve the compiler's ability to:

  • Detect and warn about potentially unsafe type inference involving generics.
  • Prevent runtime-only type failures in generic constructors by enforcing stricter compile-time checks.

Dart Version (on macOS 15.5 24F74 darwin-arm64)

Dart version 3.7.2DevTools version 2.42.3
@eernstg
Copy link
Member

eernstg commented May 16, 2025

This is a well-known issue. You might want to take a look at dart-lang/language#524, which is a request for statically checked variance (please vote for that issue if you wish to help promoting this feature). With the feature, you can mark the classes that give rise to the run-time failure, and turn the dangerous typing situation into a compile-time error:

class KLineChartData<inout T> { ... }
class KLineChart<inout T> { ... }

You'd need to enable the experiment 'variance' in order to allow those inout modifiers to be there. Note that it is not fully implemented, but you can use the current partial implementation to see some compile-time errors, which can be helpful.

With this, you'll see that the return statement in Helper.create is wrong: The return type KLineChart means KLineChart<dynamic> (you can use stricter analysis settings) to avoid the use of dynamic as a type argument in situations like this). This return type is not a supertype of the returned expression's static type, which is KLineChart<int>.

In the given example it is actually sufficient to change this return type, and the example runs without errors.

The underlying issue is that it is unsafe to use a covariant type variable in a non-covariant position in the signature of a member. See dart-lang/language#296 and dart-lang/language#297 for more info on this particular typing situation.

In short, this means that the declaration of KLineChartData.itemToValue is dangerous.

Unfortunately, we can't just make it safe, for that we'd need the 'variance' feature. However, you can enable the lint 'unsafe_variance' in order to get a heads-up if you ever declare such a member again (or someone else does ;-).

So the first advice is simply to avoid declaring members that are flagged by this lint.

However, there are several other ways to deal with this kind of issue, apart from simply avoiding the non-covariantly typed member declarations. Take a look at the many issues linked from dart-lang/language#524 in order to see much more about this topic.

In particular, you can maintain a special discipline in the usage of non-covariantly typed members: Make them private and make sure that they are never accessed on any other receiver than this. The point is that non-covariant types are unsafe when the type parameter in question is not in scope (that is, for calls on a receiver which isn't this), but they are safe when the type parameter is in scope:

class KLineChartData<T> {
  const KLineChartData({
    required this.items,
    required KChartItemToValueCallback<T> itemToValue,
  }) : _itemToValue = itemToValue;

  final List<T> items;
  final KChartItemToValueCallback<T> _itemToValue;
  double itemToValue(T item) => _itemToValue(item);
}

With this, you won't get the run-time failure at the time where you are calling itemToValue, because it is no longer one of those particularly dangerous members that unsafe_variance is targeting. The lint unsafe_variance will still flag the private member _itemToValue because it can't (or at least: doesn't attempt to) prove that all invocations of _itemToValue will have this as the receiver, but you could check this constraint manually and then use // ignore:unsafe_variance to indicate that you know what you are doing.

Another thing you can do is to emulate the statically checked invariance manually. This can be done by introducing an otherwise unused type parameter and a type alias which enforces that the extra type parameter is used in a specific manner (at least outside the library where it is declared):

import 'dart:developer';

typedef _Inv<X> = X Function(X);

typedef KLineChartData<X> = _KLineChartData<X, _Inv<X>>;

class _KLineChartData<T, Invariance extends _Inv<T>> {
  const _KLineChartData({required this.items, required this.itemToValue});

  final List<T> items;
  final KChartItemToValueCallback<T> itemToValue;
}

typedef KChartItemToValueCallback<T> = double Function(T item);

typedef KLineChart<X> = _KLineChart<X, _Inv<X>>;

class _KLineChart<T, Invariance extends _Inv<T>> {
  final KLineChartData<T> data;

  const _KLineChart({required this.data});

  void print() {
    final values = data.items.map((item) => data.itemToValue(item)).toList();
    inspect(values);
  }
}

// In some other library.

class Helper {
  Helper._();

  // It's now a compile-time error to omit the type argument in the return type.
  static KLineChart<int> create() {
    final data = [1, 2, 3, 4, 5, 6, 7];

    final chartData = KLineChartData(
      items: data,
      itemToValue: (item) => item.toDouble(),
    );

    return KLineChart(data: chartData);
  }
}

void main() {
  final chart = Helper.create();
  chart.print();
}

This technique will faithfully emulate the typing properties of making the type parameters of KLineChartData and KLineChart invariant (like the inout keyword), and it works today (including the parts that aren't implement in the variance feature). However, it does make it harder to create subtypes, so it may or may not be worthwhile in your case.

@lrhn
Copy link
Member

lrhn commented May 16, 2025

The issue is due to generic type inference*. In the create() method, no explicit generic type is provided for KLineChart, so Dart infers dynamic:

To be pedantic, that is not generic type inference, which usually tries to find the best type arguments for something based on hints from the context or contents. There is no inference here, the "raw" type is simply has its blanks filled in by instantiating to bounds.

Generally, if you write a raw generic type as a type, aka. a "type annotation", there is no generic inference.
Generic inference only applies for expressions where the type occurs in a constructor, and recently also in object patterns.

When a raw identifier denoting a generic type is used as a type, like writing List, it is just short for List<dynamic>, and you'd usually be better off writing nothing. Then you might get an inference of a whole List<Something> type.

(And it's a know annoyance, I'd love to try to infer type parameters for all raw types, if there is anything to infer them from. It's just not always possible, and would sometimes break existing code which depends on the dynamic.)

The quickest fix here would be:

static KLineChart<int> create() {

@xeinebiu
Copy link
Author

This is a well-known issue. You might want to take a look at dart-lang/language#524, which is a request for statically checked variance (please vote for that issue if you wish to help promoting this feature). With the feature, you can mark the classes that give rise to the run-time failure, and turn the dangerous typing situation into a compile-time error:

class KLineChartData { ... }
class KLineChart { ... }
You'd need to enable the experiment 'variance' in order to allow those inout modifiers to be there. Note that it is not fully implemented, but you can use the current partial implementation to see some compile-time errors, which can be helpful.

With this, you'll see that the return statement in Helper.create is wrong: The return type KLineChart means KLineChart<dynamic> (you can use stricter analysis settings) to avoid the use of dynamic as a type argument in situations like this). This return type is not a supertype of the returned expression's static type, which is KLineChart<int>.

In the given example it is actually sufficient to change this return type, and the example runs without errors.

To me, it is surprising it works fine when a local variable is used to return the object, but if directly returned, the issue persists.

@eernstg
Copy link
Member

eernstg commented May 16, 2025

To me, it is surprising it works fine when a local variable is used to return the object, but if directly returned, the issue persists.

That's surely because the local variable had a better declared type than KLineChart<dynamic>, possibly because you actually wrote something like KLineChart<int>, or because you did not write a type and KLineChart<int> was inferred.

In general, the context type of an expression plays an important role for the type inference performed on that expression.

void main() {
  List<Object> xs = [1, 2, 3];
  xs.add("Hello!"); // OK, it's a `List<Object>`.

  List<int> ys = [1, 2, 3]; // .. or this: `var ys = [1, 2, 3];`, which works the same.
  List<Object> zs = ys; // Yes, because of the dynamically checked variance, this is allowed.
  zs.forEach((x) => print(x)); // No problems.
  zs.add("Hello"); // No compile-time error, but throws at run time.
}

So the difference between returning something directly and returning a local variable which is initialized to the "same" value is that the former will have the declared return type of the enclosing function as its context type, and the latter will have the type of the variable (and that variable might not declare a type at all, so the expression is inferred with no constraints from the outside).

@eernstg
Copy link
Member

eernstg commented May 16, 2025

I'll close this issue because the topic has been unfolded in detail before. Thanks for reporting it, it's useful to confirm that it matters!

@eernstg eernstg closed this as completed May 16, 2025
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

3 participants