Skip to content

Add a poor man's version of branded types, and lint inconsistencies that are otherwise invisible to the type system #57828

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
eernstg opened this issue Nov 16, 2018 · 2 comments
Labels
devexp-linter Issues with the analyzer's support for the linter package legacy-area-analyzer Use area-devexp instead. linter-lint-request type-enhancement A request for a change that isn't a bug

Comments

@eernstg
Copy link
Member

eernstg commented Nov 16, 2018

This is a proposal for a lint that flags inconsistent usages of a sort of branded types.

A bit of motivation first: One well-known source of bugs is the use of the same type for different purposes, which makes it possible to pass arguments with one meaning in the application domain to formal parameters with a different meaning. Here is an example:

void feedPerson(Person p, int age, int weight, Food f) {...}

void helpPerson(Person p, int weight, int age) {
  ...
  if (p.hungry) feedPerson(p, weight, age, myBread); // Should be `... age, weight ...`!
}

In this example, it's very likely to be a bug that we pass the actual argument weight to the formal parameter named age of feedPerson and vice versa, but the type system can't see any problems because they are both just of type int.

Branded types are all about being able to create different "copies" of any given type, such that we can detect this kind of problem: A branded type T1 is a copy of an existing type T which is considered to be different from another branded type T2 when T2 was created from a different type than T, but also when T2 was created from T. For instance, we could create branded types Weight and Age from int, and we would then be able to flag inconsistent data flows:

typedef Weight = new int;
typedef Age = new int;

Weight w = 239;
Age a = 72;

main() {
  w += a; // Error.
}

Pascal has had declarations like TYPE Weight = Integer; since the 1970'ties, and Haskell has newtype which enables a simple (one-case) algebraic type to use erasure (such that we get a new type, but the run-time representation is the same as the underlying type). So we could definitely do a similar thing in Dart.

However, I do not believe that we will do this: It's a non-trivial subsystem in the type system that we'd need to add, and it is connected to a huge domain of specialized type system features that we probably don't want to promise that we'll add to the language.

Hence, this issue is aimed at doing a similar thing in terms of lints alone, with no changes to the Dart language or its type system.

We could do "manual branding" of type annotations based on metadata:

// Always a dog fight where to put this, let's assume 'package:meta/meta.dart'.
class BrandedType<X> {
  final String brand;
  const BrandedType(this.brand);
  Type type => X;
  bool includesInstance(Object o) => o is X;
}

When BrandedType is available, we could use it as follows:

import 'package:meta/meta.dart';

const ageInt = BrandedType<int>("Some globally unique string, e.g., "
    "myOrganization.2de63ac01c3f092b24da50d1080617f3244e7b05");
const weightInt = BrandedType<int>("Another globally unique string");

class Person{ bool get hungry => true; }
class Food {}

void feedPerson(Person p, @ageInt int age, @weightInt int weight, Food f) {}

void helpPerson(Person p, @weightInt int weight, @ageInt int age) {
  Food myBread;
  // ...
  if (p.hungry) feedPerson(p, weight, age, myBread); // Linted twice!
}

We could then have a lint that knows about BrandedType and checks actual arguments against formal parameters, flagging all occurrences where a value whose static type has one brand is passed to a formal parameter with a different brand (that would be in addition to normal type checks, of course). This would allow us to get the two lint messages above at the comment Linted twice!.

Apart from simple data flow restrictions, there could be arbitrary lints for derived checks. For instance, we would want to allow literals to be assigned to branded types, we might want assignments from a branded int source to a plain int target or vice versa to be accepted or rejected, we might want the product of two lengthDouble values to have brand areaDouble (cf. F# units of measure), etc.etc.

There is no end to the wealth of features that people have invented in this area, which is a good reason for handling it with lints, rather than making all these things part of the language.

One particular case that doesn't really call for a lot of magic is that of function types, cf. #57827. It is my impression that the underlying problem in that issue is that we have no way to brand function types.

In particular, using an example which is similar to the one mentioned above, we get the following:

import 'package:meta/meta.dart';

typedef A = void Function();
const Abrand = BrandedType<A>(...);

typedef B = void Function();
const Bbrand = BrandedType<B>(...);

void main() {
  var set = Set();
  set.add(Abrand);
  set.add(Bbrand);
  print(set.length); // 2
}

BrandedType should not be a subtype of Type (they are used differently), but a developer could make the choice to use BrandedType instances as a near replacement for Type in some idioms:

Map<BrandedType, Function> myFunctions = {
  Abrand: () { /* Do A stuff */ },
  Bbrand: () { /* Do B stuff */ },
  ...
};

void runBrand(BrandedType brand, Function newF) {
  Function oldF = myFunctions[brand];

  // We can perform checks similar to `newF is T` for the type
  // associated with `brand`.
  if (brand.includesInstance(newF)) {
    myFunctions[brand] = newF;
  }
  // If we wish to check that the function can be called in a
  // specific manner we can check that directly.
  if (oldF is Function()) oldF();
}

main() {
  // A and B functions can coexist:
  A aFunction = myFunctions[Abrand];
  B bFunction = myFunctions[Bbrand];

  // Can be used dynamically.
  runBrand(aBrand, () { /* Function intended to be used as aBrand */ });
}
@parlough
Copy link
Member

@eernstg Are the ideas you raised here sufficiently covered by extension types? :)

@eernstg
Copy link
Member Author

eernstg commented Dec 13, 2023

Good catch! I think so. Here are the examples expressed using extension types:

class Person {
  bool get hungry => true;
}
class Food {}

Food get myBread => Food();

extension type Age(int it) implements int {}
extension type Weight(int it) implements int {}

void feedPerson(Person p, Age age, Weight weight, Food f) {/*...*/}

void helpPerson(Person p, Weight weight, Age age) {
  // ...
  if (p.hungry) feedPerson(p, weight, age, myBread); // Should be 'age, weight'; is compile-time error now.
}
extension type Weight(int it) implements int {}
extension type Age(int it) implements int {}

Weight w = Weight(239);
Age a = Age(72);

main() {
  w += a; // Compile-time error.
}

The original posting suggests that we could use a BrandedType class to model branded types:

// Always a dog fight where to put this, let's assume 'package:meta/meta.dart'.
class BrandedType<X> {
  final String brand;
  const BrandedType(this.brand);
  Type get type => X;
  bool includesInstance(Object o) => o is X;
}

The part which is concerned with compile-time branding is obsolete: Extension types do that in a much more language-integrated manner.

However, it might still be relevant to consider BrandedType as a kind of programming idiom for capturing branding of run-time entities associated with types. So here's the last example again:

class BrandedType<X> {
  final String brand;
  const BrandedType(this.brand);
  Type get type => X;
  bool includesInstance(Object o) => o is X;
}

typedef A = void Function();
const aBrand = BrandedType<A>('2de63ac01c3f092b24da50d1080617f3244e7b05');

typedef B = void Function();
const bBrand = BrandedType<B>('7f3244e7b052de63ac01c3f092b24da50d108061');

Map<BrandedType, Function> myFunctions = {
  aBrand: () { print('Do A stuff'); },
  bBrand: () { print('Do B stuff'); },
};

void runBrand(BrandedType brand, Function newF) {
  Function oldF = myFunctions[brand]!;

  // We can perform checks similar to `newF is T` for the type
  // associated with `brand`.
  if (brand.includesInstance(newF)) {
    myFunctions[brand] = newF;
  }
  // If we wish to check that the function can be called in a
  // specific manner we can check that directly.
  if (oldF is Function()) oldF();
}

main() {
  // A and B functions can coexist:
  A aFunction = myFunctions[aBrand] as A;
  B bFunction = myFunctions[bBrand] as B;
  
  aFunction();
  bFunction();

  // Can be used dynamically.
  runBrand(aBrand, () { print('Used as aBrand'); });
}

This notion that we can create a somewhat more powerful representation of reified types than instances of Type is not new, and it remains possible to do a number of things using classes like BrandedType. However, all the brandedness in this technique is very "manual" (we don't get help from the language). Hence, it is not that likely that it is worth doing, considering that the support for compile-time branding of existing types is available as a real language mechanism based on extension types.

So we're done here!

@eernstg eernstg closed this as completed Dec 13, 2023
@devoncarew devoncarew added devexp-linter Issues with the analyzer's support for the linter package legacy-area-analyzer Use area-devexp instead. labels Nov 18, 2024
@devoncarew devoncarew transferred this issue from dart-archive/linter Nov 18, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
devexp-linter Issues with the analyzer's support for the linter package legacy-area-analyzer Use area-devexp instead. linter-lint-request type-enhancement A request for a change that isn't a bug
Projects
None yet
Development

No branches or pull requests

4 participants