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

Typed Maps - like interfaces in TypeScript #783

Open
KoTTi97 opened this issue Jan 16, 2020 · 41 comments
Open

Typed Maps - like interfaces in TypeScript #783

KoTTi97 opened this issue Jan 16, 2020 · 41 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@KoTTi97
Copy link

KoTTi97 commented Jan 16, 2020

I would love to see typed Maps in Dart like it is possible in TypeScript.

TypeScript example:

interface IPerson {
    name: string;
    age: number;
    height?: number;
}

const person: IPerson = {
    name: "Max",
    age: 26
}

I wish Dart would support something like that so i do not have to create a class for it. It could look something like this:

interface IPerson {
  String name;
  int age;
  height? double;
}

Map<IPerson> person = {
  name: "Max",
  age: 26,
} 
@KoTTi97 KoTTi97 added the feature Proposed language feature that solves one or more problems label Jan 16, 2020
@roman-vanesyan
Copy link

You can already do it by using abstract class.

abstract class A {
  String get name;
  int get age;
  double? get height;
}

class B implements A {
  B({this.name, this.age, this.height});

  final int age;
  final String name;
  final double? height;
}

Still it is not possible to make map to implement abstract class as Map is just yet another class and thus { key: value } is just consice instantiation of that class.

@Cat-sushi
Copy link

@KoTTi97
If you want to construct instances in map like syntax without manually defining such constructor, then #698 might be a solution.

@rrousselGit
Copy link

@vanesyan that's a different thing

Typed maps, aka structures/records, have a very different assignment behavior.

@kennethnym
Copy link

The "map" in TypeScript is different because it's a class, not a map. There's a Map class in TypeScript/JavaScript that is basically the same as maps in Dart. Plus, it's already possible to do what you want in Dart:

class Person {
  final String name;
  final int age;
  final double height;

  const Person({
    this.name,
    this.age,
    this.height,
  });
}

final Person person = Person(
  name: 'Max',
  age: 26
);

Introducing this basically means mixing two completely different concepts.

@g5becks
Copy link

g5becks commented Oct 6, 2020

@KoTTi97 it's not maps you want, in typescript / javascript, these are called object literals. I would love to see this represented in dart as well via anonymous classes, the main use case for me is simpler json encodeing/decoding. In Go they are quite convenient as well as they remove a lot of boilerplate code that is created soley for sending / recieving responses.

car := struct {
	make    string
	model   string
	mileage int
}{
	make:    "Ford",
	model:   "Taurus",
	mileage: 200000,
}

Whats missing in dart is a way to declare a type inline as opposed to using a class declaration. The typescript example you showed doesn't really buy you anything when compared to what dart can already do, when this becomes useful is when declaring generic types, function return types and parameters, also variable types

const someFunc = (user: {name: string, age: number}): {data: {didSucceed: boolean } } => {
    const data: { didSucceed: boolean } = methodCall()
    return {data}
}

In dart you would have to define a class for every type that was defined inline here.

It would be nice to have something similar in dart, but - It will probably never happen - Typescript and Go are structurally typed,
and Dart isn't. C# has them, but they suffer from the same problems that dart does due to the type system and are only useful in the context of linq, they can't be used anywhere else.

I think Data classes are a great alternative and will alleviate most if not all of the pain in situations where anonymous objects would be used.

@lrhn
Copy link
Member

lrhn commented Oct 6, 2020

This looks something like Java's anonymous classes. In Dart, that would probably look like:

var foo = new Object() {
  int something = 42;
  String message = "Banana";
};

It won't give you any type for it, you'll still have to declare an interface:

abstract class Foo {
  abstract final int something;
  abstract final String message;
}
...
  var foo = const Foo() {
    final int something = 42; 
    final String message = "some text";
  };

Then foo will have a type which implements Foo.

@rrousselGit
Copy link

Anonymous classes aren't the same thing imo.
Anonymous classes is about first-class citizen class.

Typed maps/structures/records are a mechanism separate from classes. They don't have constructors/inheritance.

@g5becks
Copy link

g5becks commented Oct 6, 2020

@lrhn

I'm not sure that buys you anything over data classes for simple objects that are just containers for a set of fields with different types.

It might be useful if you are actually implementing an interface's methods though.

abstract class Foo {
  Future<bool> doSomething();
}

var foo = const Foo() {
   Future<bool> doSomething() async {
      return false;
 }
}

I can't really think of a use case for this off the top of my head, but F# has them so I guess they are a useful somehow?

Seeing as how Dart has a nominal type system, I'm not sure how anonymous objects with structural equality could be supported (aside from some from of runtime reflection maybe? ), but for the op - If/when Dart lands support for Type Aliases and Sum Types , you can (almost) solve the same set of problems in a different way, E.G.

enum SomeUnionType {
    OneType,
    AnotherType,
    LastType,
}
typedef MyType = Map<String, SomeUnionType>;

MyType  myInstance =  { "key1": OneType(), "key2": AnotherType(), "key3": LastType() };

// destructuring based on key name would help also if this feature is added. 
var { key1, key2, key3 } = myInstance; 

@lrhn
Copy link
Member

lrhn commented Oct 6, 2020

Dart has structural types too: Function types, FutureOr and (with Null Safety) nullable types. It's not being non-nominal that's the biggest challenge, it's having a type which is not assignable to Object?. The structural types that we have are still backed by actual objects.

@g5becks
Copy link

g5becks commented Oct 6, 2020

I'm not a language designer, so I am not so sure about the implementation. In typescript, the type doesn't exist at runtime anyway so they can just do whatever. For Go, I'm not sure - the struct cant be assigned to interface{} unless it's a pointer. In Scala the type is assignable to Object, but then runtime reflection is used for field access, which obviously won't work for dart.

@jodinathan
Copy link

I miss this too.
The point is that the interface should not exist so it would be basically a desugar/analyzer thing:

interface Foo {
  String bar;
}

// example 1
var baz = <String, dynamic>{
  'bar': 'hi'
};
var foo = baz as Foo;

print(foo.bar); // prints hi
// the above would be basically a de-sugar to print(foo['bar']);

// example 2
var baz = Foo()..bar = 'hello';
// desugering to
var baz = <String, dynamic>{
  'bar': 'hello'
};

@jodinathan
Copy link

jodinathan commented Oct 6, 2020

I guess this kinda exists when you use JS interop:

@JS()
@anonymous
class Foo {
  external String get bar;
  external set bar(String n);

  external factory Foo({String bar});
}

var foo = Foo(bar: 'hi');
// I guess the above line transpiles to something like in JS:
let foo = {
  'bar': 'hi'
};

@cedvdb
Copy link

cedvdb commented Mar 29, 2021

For decoding where you have to change values a lot, putting them in a map where each key is a string makes more sens than to create a class for each return value. The problem is that the compiler doesn't know which key the map contains. I think this is what people would like. That the compiler knows which key the map contains without having to explicitly tell the compiler (via a class or alternatively enums for the keys) . This would be super convenient for some people.

@lrhn
Copy link
Member

lrhn commented Mar 30, 2021

If you can specify the name and type of each entry in the "map" (and you need to if you're going to have any kind of static typing), that sounds like all the information needed for declaring a class.
All you need is a more concise "data-class" syntax. If you could write, say:

class Person(final String name, int age, [num? height]);

to declare that class, then using a map-like construct doesn't seem particularly enticing any more.

@kasperpeulen
Copy link

kasperpeulen commented Mar 30, 2021

This proposal seems very much in line with how Typescript does it:
https://github.com/dart-lang/language/blob/master/working/0546-patterns/records-feature-specification.md

It also based on structural typing instead of nominal typing.
I guess the opening post would look like this with Records.

typedef Person = {String name, int age, num? height};

const Person person = (
  name: "Max",
  age: 26,
  height: null,
);

@cedvdb
Copy link

cedvdb commented Mar 30, 2021

typedef Person = {String name, int age, num? height};

const Person person = (
  name: "Max",
  age: 26,
  height: null,
);

That's awesome. Bonus point because I assume it would be easily serializable with json. I thought I needed meta programing or data class but this could fit the bill imo

@kasperpeulen
Copy link

Yes, exactly, I didn't see anything about json serializing in the proposal, but I guess it could technically be done, and could also be faster than it is now if done with records.

@jodinathan
Copy link

the Records proposal along with meta programming proposal would rock Dart world

@cedvdb
Copy link

cedvdb commented Mar 30, 2021

the Records proposal along with meta programming proposal would rock Dart world

Yeah I hope it doesn't take too long.

const Person person = (
  name: "Max",
  age: 26,
  height: null,
);

The properties have to be accessible without string like so person.name. This is looking a bit like the data class struct request.

It would be easy to serialize to json:

// assuming the keys are strings
person.entries.fold({}, (prev, curr) => prev[curr.key] = curr.value);

@kasperpeulen
Copy link

@cedvdb

The proposal mentions the static method namedFields:

abstract class Record {
  static Iterable<Object?> positionalFields(Record record);
  static Map<Symbol, Object?> namedFields(Record record);
}

However, because it uses Symbol, it can not be used to serialize without reflection, see:
#1276

There is also discussion if namedFields should be exposed at all without reflection:
#1277

I hope there is some solution for this possible, I think it would be great if there is something possible in Dart that lets you serialize and deserialize fields, that is:

  • fast
  • doesn't need code generation or reflection
  • and is compile-time safe

@jodinathan
Copy link

@kasperpeulen #1482

@venkatd
Copy link

venkatd commented Apr 30, 2021

Structural typing would make a huge difference for interoperability with external systems. For example, we are working with a GraphQL API which is structurally typed.

Some parts of our code work with a BasicUser which is just id, name, photo. Other parts may need a UserWithProfile which has id, name, photo, and several other fields. We would want UserWithProfile to be usable in place of BasicUser since it's a strict superset. If we have a UserAvatar widget, we don't want to have to explicitly accept every single variant of a User type.

So, because we effectively want structural typing, we hack around it by explicitly implementing a bunch of interfaces in our generated code.

In TypeScript, this is implemented as a Pick:
https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys

@jodinathan
Copy link

is this on the radar?
or another solution because working with json in dart is way too boring and very time consuming

@jakemac53
Copy link
Contributor

@jodinathan
Copy link

is this the issue in the language project funnel? #1474

@jakemac53
Copy link
Contributor

is this the issue in the language project funnel? #1474

Looks like it to me :)

@eernstg
Copy link
Member

eernstg commented Nov 10, 2021

I think there are several topics in this issue. I'll say something about a couple of them.

Let's consider the original example:

interface IPerson {
  String name;
  int age;
  height? double;
}

Map<IPerson> person = {
  name: "Max",
  age: 26,
}

We don't (yet) have interface declarations in Dart, but the would presumably be a special variant of classes that support implements (so clients can create subtypes of them), but not extends or with. So the interface declared here would presumably work in a similar way as

abstract class IPerson {
  abstract String name;
  abstract int age;
  abstract height? double;
  // And we could add a private constructor to prevent
  // `extends IPerson` in another library.
}

Map<IPerson> would have to be something like ObjectMap<IPerson>, because Map takes two type arguments (and it's not very interesting to drag the discussion about static overloading of type parameter lists into this discussion, so we might as well just use different names for now). ObjectMap would be a new platform class, with some special magic.

In particular, we'd use plain identifiers to denote each key, requiring that is the name of a member of IPerson, and the corresponding value would be statically checked to have a type which is assignable to the given member.

We could do all these things, but there are many missing pieces of the puzzle:

Should ObjectMap<IPerson> be a subtype of IPerson? Should ObjectMap<IPerson> be a subtype of Map<K, V> for any K, V (maybe Symbol and dynamic)? What do we do if IPerson and Map both declare a member with the same name and incompatible signatures?

We could also ask whether ObjectMap<C> would form a subtype graph which is covariant in C, e.g., whether ObjectMap<int> would be a subtype of ObjectMap<num>?

We could ask whether C in ObjectMap<C> would have to satisfy some extra constraints, e.g., that it is a compile-time error if C declares any static members, or any instance members which are not instance variables, or if those instance variables are final or late, etc.etc., such that the behavior of an instance of type ObjectMap<C> could work like a map and also like an instance of C.

We could ask whether there would be a structural subtype relationship, that is, ObjectMap<C1> would be a subtype of ObjectMap<C2> if the set of members of C2 is a subset of the set of members of C1 (and in that case we wouldn't require that there is any subtype relationship between C1 and C2).

My take on this is that we could go down this path, but it does involve a lot of detailed language design (and possibly a large amount of implementation work in order to handle new kinds of subtyping relationships), and it's not obvious to me that it is a good fit for the language: Dart objects are not maps, and it would be a huge change to make them so. (I think that's a feature, not a bug. ;-)


However, we could turn this around and consider a possible underlying feature request which is much more compatible with Dart. Let's reconsider the example here and assume that we have enhanced default constructors:

class Person {
  final String name;
  final int age;
  final double? height;
}

final Person person = Person(
  name: 'Max',
  age: 26,
);

This doesn't involve anonymous types, or structural subtyping, but it does allow for the declaration of a class and construction of instances based on a syntax which is similarly concise as the original example, and it preserves full static typing in a completely straightforward manner.


If the point is, instead, that we want to treat certain maps safely as certain existing class types then it is indeed possible to use a view to do it:

abstract class IPerson {
  abstract String name;
  abstract int age;
  abstract height? double;
}

view IPersonMap on Map<Symbol, Object?> implements IPerson {
  String get name => this[#name] as String;
  set name(String value) => this[#name] = value;
  // and similarly for `age` and `double`.
}

void main() {
  Map<Symbol, Object?> map = { #name: 'Max', #age: 26 };
  IPersonMap p = map;

  // `p` is now treated statically safely, with the same interface as an `IPerson`.
  print(p.name);
  p.name = 'Joe';
}

The view is a static mechanism (and not yet part of the language), but it includes a box getter that returns an instance whose dynamic (and static) type is IPerson, and that's a full-fledged object which would also support things like dynamic method invocations and is tests.

This is of course a lot more verbose, and the purpose is completely different: This mechanism is aimed at supporting a manually specified statically safe treatment of objects of a freely chosen underlying implementation type. The point is that the implementation type (Map<Symbol, dynamic> here) is unsafe, and we want to restrict the usage of the given map to an existing interface, and then we just have to implement the latter in terms of the former (in this case: translating object member accesses to map operations).

@jodinathan
Copy link

views is a very interesting feature @eernstg.

I can see a builder to make it easy to JSON interop. Would the below work?

// we take an abstract class and add the Interface annotation
@Interface()
abstract class Person {
  abstract String name;
  abstract int age;
  abstract height? double;
}

// generate the view Interface through the builder
view PersonInterface on Map<String, Object?> implements Person {
  String get name => this['name'] as String;
  set name(String value) => this['name'] = value;

  // and similarly for `age` and `double`.
}

// declare some API bindings
class SomeApi {
  // expose the Person endpoint
  Future<Person> fetchPerson() async {
    // fetch some map
    final resultMap = await ajaxSomePersonMap();
   
    // return as the Person interface
    return resultMap as PersonInterface;
  }
}

Future<void> main() async {
  final api = SomeApi();
  // call the API in a object oriented way
  final person = await api.fetchPerson();

  // typed =]
  print(person.name);
}

This along with static meta programming can finally make working with json easier and satisfying.

Question: I understood from the proposal that views are going to be very lightweight, thus our example should be pretty close to manually using a Map, right?

@eernstg
Copy link
Member

eernstg commented Nov 10, 2021

@jodinathan, we'd need a couple of adjustments:

@Interface()
abstract class Person {...} // Same as before.

view PersonInterface on Map<String, Object?> implements Person {...} // Same as before.

class SomeApi {
  Future<Person> fetchPerson() async {
    final resultMap = await ajaxSomePersonMap();
    return resultMap.box;
  }
}

void someOtherFunction(Person p) {...}

Future<void> main() async {
  final api = SomeApi();
  final person = await api.fetchPerson();

  // Statically typed; `person` has type `Person`.
  print(person.name);
  someOtherFunction(person);
}

This would work, and the object which is the value of person in main would be a full-fledged Dart object of type Person. In this case you're paying (in terms of time and space) for a wrapper object, and in return you get normal object-oriented abstraction (you can assign person to a variable of type Object and restore the type by doing is Person) as well as dynamic invocations.

You could also maintain a more lightweight approach where the map isn't wrapped in a different object. In this case all instance method invocations would be static (that is, there is no OO dispatch and they could be inlined), but in return you must maintain the information that this is actually a PersonInterface view, and not an actual object of type Person.

But you still have the Person interface, and it is still checked statically that you only access the underlying map using those view method implementations:

@Interface()
abstract class Person {...} // Same as before.

view PersonInterface on Map<String, Object?> implements Person {...} // Same as before.

void someOtherFunction(Person p) {...}

class SomeApi {
  Future<PersonInterface> fetchPerson() async => await ajaxSomePersonMap();
}

Future<void> main() async {
  final api = SomeApi();
  final person = await api.fetchPerson();

  // Statically typed; `person` has type `PersonInterface`.
  print(person.name);

  // You can still use `person` as a `Person`, but then it must be boxed.
  someOtherFunction(person.box); // Just passing `person` is a compile-time error.
}

The point is that you can pass the Map around under the type PersonInterface, and this means that you can store it in a list or pass it from one function to another, and you'll never need to pay (time or space) for a wrapper object. But if you insist on forgetting that it is a view then you must box the map, which is then simply a normal Dart object of type Person.

@jodinathan
Copy link

in the json use case, the view is enough.

can we pass the interface around?

view PersonInterface on Map<String, Object?> implements Person {...} // Same as before.

void someOtherFunction(PersonInterface p) {
  print(p.name);
}

Future<void> main() async {
  final api = SomeApi();
  final person = await api.fetchPerson();

  someOtherFunction(person);
}

@eernstg
Copy link
Member

eernstg commented Nov 12, 2021

Yes, inside someOtherFunction it is statically known that p is a Map<String, Object?> which must be handled using the members of PersonInterface, so there's no need for a wrapper object. On the other hand, then we can't invoke someOtherFunction on an argument of type Person, so Person and PersonInterface are strictly separate types, statically and dynamically. They just happen to support exactly the same members because we have constrained PersonInterface to implement Person (OK, PersonInterface could have additional members as well, but it can't be incomplete or contradict anything in Person).

@jodinathan
Copy link

@eernstg this is awesome!

We really need this 🥺

@ciriousjoker
Copy link

Any progress? Typescript is amazing in this regard.

@cedvdb
Copy link

cedvdb commented Apr 15, 2022

The lack of this feature is error prone when adding a property to an serializable class. The fromMap will fail to compile while the toMap won't. Hopefully macros will help there.

@bernaferrari
Copy link

Records feature is kind of this.

@ddzy
Copy link

ddzy commented Nov 2, 2023

any progress?

@bernaferrari
Copy link

This is done already..

@lrhn
Copy link
Member

lrhn commented Nov 2, 2023

It is very, very unlikely that Dart will introduce a "map type" which you can check whether an existing normal map "implements".
Can't do someMap is {"name": String, "age": int} and then somehow know that a later someMap["name"] has type String, because Dart maps cannot guarantee that they preserve map type or value over time.
Instead you should check the structure and extract the values at the same time, which is exactly what map patterns do.

For having simple structures with typed fields, Records is probably the most direct implementation of what's being asked for here, and they exist since Dart 3.0.
Except for the optional value, where you would have to explicitly write the null.

The next best thing would be "data classes", which could be defined something like my example above.

The one thing that's not being asked for, but is probably wanted, is JSON encoding and decoding.
Records do not support that. Data classes could using macros.

The value will not be a Map. The type system is unlikely to get a special map type which knows the type of its individual entry values (and which only allows string keys). It's not a good fit for a language which, well, is not JavaScript
All JavaScript objects are "maps", so class types are mostly "map types".
In other languages, maps are homogenous data structures, and objects implement interfaces, two completely different kinds of objects.

Still, it's not completely impossible.
Dart could introduce a "typed map" type, where the type carries the keys (which will likely need to be consistent and have primitive equality), and the instance carries the values. Very similar to records, a fixed structure parameterized by values. Using the [] operator on that type is not a method call if the argument is a constant, but a special syntactic form which has the type of the entry if the value is a constant. If not constant, or not called at that type, it is a method lookup which has type Object?, out maybe a specified supertype of all values. Typed maps can be immutable or you can be allowed to update the fields that exist, but not change the structure. Some fields can be optional, and their presence can be checked using containsKey. Can be assigned to Map<Object?>, but not to a typed map with a different structure.
Will need some way to build, likely a constructor. If the keys are all strings containing identifiers, then the construction makes can be inferred from that, otherwise they'll have to be positional.

It's just not particularly useful.
It's not a type that you can place on an existing map, to see if it fits, it's a new kind of object which plain maps do not have. It's just another class which implements Map.
JSON encoding won't preserve the knowledge that it comes from a typed map, so decoding will have to recreate it anyway.
If you can call [constant], you can do .name, so a data class which a macro makes implement Map<String, Object?>
would be just as powerful.

I don't see anything left to do here, which is at all likely, except possibly simple data classes.

@jakemac53
Copy link
Contributor

Records do not support that. Data classes could using macros.

Macros could also add a form of support to records too fwiw, through generated helper methods to do the conversion.

@TekExplorer
Copy link

It is very, very unlikely that Dart will introduce a "map type" which you can check whether an existing normal map "implements". Can't do someMap is {"name": String, "age": int} and then somehow know that a later someMap["name"] has type String, because Dart maps cannot guarantee that they preserve map type or value over time. Instead you should check the structure and extract the values at the same time, which is exactly what map patterns do.

see pattern matching

if (map case {'name': String name, 'age': int age}) {
   print('name: $name, age: $age');
}

@TekExplorer
Copy link

also, records could support json encoding with macros too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests