dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Typed Maps - like interfaces in TypeScript #783

Open KoTTi97 opened 4 years ago

KoTTi97 commented 4 years ago

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,
} 
roman-vanesyan commented 4 years ago

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 commented 4 years ago

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

rrousselGit commented 4 years ago

@vanesyan that's a different thing

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

kennethnym commented 4 years ago

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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago
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 commented 3 years ago

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 commented 3 years ago

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

cedvdb commented 3 years ago

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 commented 3 years ago

@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: https://github.com/dart-lang/language/issues/1276

There is also discussion if namedFields should be exposed at all without reflection: https://github.com/dart-lang/language/issues/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:

jodinathan commented 3 years ago

@kasperpeulen https://github.com/dart-lang/language/issues/1482

venkatd commented 3 years ago

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 commented 2 years ago

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

jakemac53 commented 2 years ago

https://github.com/dart-lang/language/blob/master/working/1426-extension-types/feature-specification-views.md I think this proposal essentially covers this use case? cc @eernstg

jodinathan commented 2 years ago

is this the issue in the language project funnel? https://github.com/dart-lang/language/issues/1474

jakemac53 commented 2 years ago

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

Looks like it to me :)

eernstg commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

@eernstg this is awesome!

We really need this 🥺

ciriousjoker commented 2 years ago

Any progress? Typescript is amazing in this regard.

cedvdb commented 2 years ago

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 commented 1 year ago

Records feature is kind of this.

ddzy commented 10 months ago

any progress?

bernaferrari commented 10 months ago

This is done already..

lrhn commented 10 months ago

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 commented 10 months ago

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 commented 10 months ago

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 commented 10 months ago

also, records could support json encoding with macros too.

alessandro-amos commented 2 months ago

I didn't quite understand the problem ... isn't that what it's already saying?

typedef TPerson = ({ String name, int age });
TPerson person = (name: "teste", age: 10);
bernaferrari commented 2 months ago

No, because you lack person.values, keys, serialization and any functionality that expects a map

TekExplorer commented 2 months ago

Perhaps you want a map extension type?

extension type YourType(Map<String, dynamic> map) {
  String get thing => map['thing'] as String;
  int get i => map['i'] as int;
}
bernaferrari commented 2 months ago

Google (as well as many others) is using Zod to type a scheme for Gemini, so the model knows what it can return. When value is returned, it is already cast in the schema you provided. That's basically impossible in Dart right now, but this issue would allow it.

lrhn commented 2 months ago

Sounds like you'd want a Zod-to-object conversion. Or Zod-to-record.

Typed maps are an oxymoron. If it's a Dart Map, then it has precisely one key type and one value type. Having different types for different names is what objects are for.

It "works" in JavaScript/TypeScript because the type system isn't sound or safe. If you want unsound types in Dart, you need to add runtime type checks. The map itself cannot do that, so you need something around the map. Maybe an extension type.