Skip to main content
  1. The pint° programming language/

pint° v0.0.1 released

··2734 words·13 mins

In the last post I said I was working on a toy language called pint°. Today, I’m releasing its first version. In this post, I will overview what has been implemented and the current limitations.

The repository can be seen here, and the pub.dev package here.

Changes on this release #

Not exactly changes, because it’s the first release, but here are some differences from what we had the last time I posted about this language.

Scope #

For this first zero-version, the scope is basically what was described in the last post. However, as I implemented a basic type resolution step, I had to implement imports, and I implemented commentaries to help me in testing a file without having to cut and paste code into another places whenever I wanted to enable/disable a specific type definition.

The only intentional addition to the scope of the last post is the type identifier sugaring, which is described below.

However, at the time I posted the last post, many things were underspecified and there were a lot of changes I had to make to accommodate type resolving.

For instance, the type variants parameter before followed the identifier grammar, so this was not a possible definition for a type:

1
2
type List(T) = Cons(T head, List(T) tail)
             + Nil

The code before would not be parsed, as List(T) in the tail parameter is not an identifier, only the List part is.

I had to tweak the grammar a lot and update the parser accordingly to accommodate for these cases and others.

Type resolution #

I implemented a simple type resolver for pint°, which will check for common issues when defining a type. For instance:

  • we now check for types in scope. So, for instance, type Foo = Foo(T value), unless there’s an imported package with a defined type T, will throw an error;
  • we check for a type number of type parameters to check if the user passed the correct number of arguments. For instance, for List(T), declaring a parameter of type List or type List(T, U) are both errors;1
  • we check for already defined type parameters. type Foo(T, T) = ... is now an error.

Commentaries #

Commentaries are now a thing. This is not set in stone, but by now I have gone to the easy way of C-like commentaries:

  • // is a single-line commentary
  • /* and */ respectively open and close a multiline commentary (nesting is supported)

Sweet types #

I introduced some sugared syntax for common type identifiers:

  • is the top type, which compiles to Object?;
  • is the bottom type, which compiles to Never;
  • [T] means List(T), which compiles to List<T>;
  • {T} means Set(T), which compiles to Set<T>;
  • {K: V} means Map(K, V), which compiles to Map<K, V>;
  • T? means Option(T), which compiles to T?2.

Limitations #

Records #

Currently, there’s no way to express complex records in Dart. As Dart records not representable with regular type identifier syntax, this is a hard limitation with no workaround currently available.

What you can do, is use the Record supertype to receive any record, but this is far from ideal.

I didn’t want to touch records yet for two reasons:

  • they are syntactically more complex than other types, so I have to think about how I want to describe then in pint°, considering out interoperability objective;
  • I’m thinking of having records as first-class types in pint°, so they will probably work differently from how we use it on Dart.
Functions #

There’s also no way to describe function types in pint°. As currently pint° does not have support for function definitions, this is not a big problem. Type definitions can have functions as parameters, but I wouldn’t recommend it.

This can be worked around in a similar way to record, by using the Function type as a catching-all type.

Imports #

I introduced an import syntax. I don’t know if it will be final, but it’s currently as follows:

  • import @package imports Dart packages, i.e. it’s compiled to dart:package;
  • import package imports an external package, according to what is described in the pubspec.yaml file. It imports the main file. For instance, import foo imports package:foo/foo.dart;
  • import package/library imports library from the described package. For instance, import foo/bar imports package:foo/bar.dart.

All pint° programs come with dart:core implicitly imported, so basic symbols like int, String, List etc are instantly available in all pint° programs.

Limitations #

Relative imports #

Relative imports are not a thing. Some people may like it, as many use only absolute imports in Dart. I’m still deciding whether we should or not support relative imports.

For now, you can use absolute imports with the full notation. For instance, if you are working in a file <project>/lib/src/feature/implementation.dart and you want to import <project/lib/src/feature/helper.dart, you can import it with import project/src/feature/helper.

Redundant imports #

Importing the same package many times is an undefined behavior. I have no idea if this breaks the resolver or will simply do nothing. Ideally, we want it to be at least a warning to do this and be sure that doing this won’t break anything.

Modifiers #

Modifiers like shows, hides and as are not available and there’s no way to workaround it.

Other minor changes #

  • Now, type definitions generated code have a toString override that contains the values of all the fields.

How to try it #

The package is published in the pub.dev. It contains all the code for the lexer, parser, resolver, and compiler.

For a quick test, an executable is also available, that can be installed by activating it:

dart pub global activate pinto

From this, you can use pinto <pinto_file> to get the compiled file outputted to your stdout.

Here’s a sample code you can try:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import @async

type Unit = Unit

type Bool = True + False

type Option(T) = Some(T value)
               + None

type Either(T, U) = Left(T value)
                  + Right(U value)

type Complex(T) = Complex(
  [] listOfAny,
  [T] listOfT,
  {T} set,
  {T: T} map,
  T? maybeT,
  Future(T) futureOfT,
  int aSimpleInt,
  {{T?} : [Future(T)]} aMonster // I was pretty sure I supported trailing commas, but I think we have a bug
)

This generates the following Dart code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import 'dart:async';

final class Unit {
  const Unit();

  @override
  bool operator ==(Object other) => other is Unit;

  @override
  int get hashCode => runtimeType.hashCode;

  @override
  String toString() => 'Unit';
}

sealed class Bool {}

final class True {
  const True();

  @override
  bool operator ==(Object other) => other is True;

  @override
  int get hashCode => runtimeType.hashCode;

  @override
  String toString() => 'True';
}

final class False {
  const False();

  @override
  bool operator ==(Object other) => other is False;

  @override
  int get hashCode => runtimeType.hashCode;

  @override
  String toString() => 'False';
}

sealed class Option<T> {}

final class Some<T> implements Option<T> {
  const Some({required this.value});

  final T value;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Some<T> && other.value == value;
  }

  @override
  int get hashCode => value.hashCode;

  @override
  String toString() => 'Some(value: $value)';
}

final class None implements Option<Never> {
  const None();

  @override
  bool operator ==(Object other) => other is None;

  @override
  int get hashCode => runtimeType.hashCode;

  @override
  String toString() => 'None';
}

sealed class Either<T, U> {}

final class Left<T> implements Either<T, Never> {
  const Left({required this.value});

  final T value;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Left<T> && other.value == value;
  }

  @override
  int get hashCode => value.hashCode;

  @override
  String toString() => 'Left(value: $value)';
}

final class Right<U> implements Either<Never, U> {
  const Right({required this.value});

  final U value;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Right<U> && other.value == value;
  }

  @override
  int get hashCode => value.hashCode;

  @override
  String toString() => 'Right(value: $value)';
}

final class Complex<T> {
  const Complex({
    required this.listOfAny,
    required this.listOfT,
    required this.set,
    required this.map,
    required this.maybeT,
    required this.futureOfT,
    required this.aSimpleInt,
    required this.aMonster,
  });

  final List<Object?> listOfAny;

  final List<T> listOfT;

  final Set<T> set;

  final Map<T, T> map;

  final T? maybeT;

  final Future<T> futureOfT;

  final int aSimpleInt;

  final Map<Set<T?>, List<Future<T>>> aMonster;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Complex &&
        other.listOfAny == listOfAny &&
        other.listOfT == listOfT &&
        other.set == set &&
        other.map == map &&
        other.maybeT == maybeT &&
        other.futureOfT == futureOfT &&
        other.aSimpleInt == aSimpleInt &&
        other.aMonster == aMonster;
  }

  @override
  int get hashCode => Object.hash(
        listOfAny,
        listOfT,
        set,
        map,
        maybeT,
        futureOfT,
        aSimpleInt,
        aMonster,
      );

  @override
  String toString() =>
      'Complex(listOfAny: $listOfAny, listOfT: $listOfT, set: $set, map: $map, maybeT: $maybeT, futureOfT: $futureOfT, aSimpleInt: $aSimpleInt, aMonster: $aMonster)';
}

I obviously wouldn’t recommend using it in anything meant for production. We are going to have breaking changes each new release, there won’t be any stability soon, but we can have some fun trying this language.

Please, if you find any bugs, feel free to open an issue or a pull request.

Some future ideas #

An overview of the type system #

I didn’t think too much about the type system yet, but there’s one problem in Dart that I want to avoid: Dart’s type system has three top types, Object?, dynamic and void.

flowchart BT O[Object] --> O? Null --> O? O?[Object?] --> V[void] V --> O? O? --> D[dynamic] D --> V D --> O? V --> D T? --> O? T? --> V T? --> D T? --> Null T --> T? T --> O? T --> O T --> V T --> D Never --> T? Never --> Null Never --> T
It looks terrible. Because it is.

There are many reasons for this, but I want to avoid it altogether. pint°’s type system will streamline this by having a single top type, 3.

flowchart BT Top[⊤] T --> Top Opt["Option(T)"] --> Top Bottom[⊥] --> T Bottom --> Opt
By having a single top type, we have a simpler type system.

Another difference, which is demonstrated in the charts above, is the Null type. In Dart, the Null type is a singleton type4 inhabited by null itself. There’s nothing inherently bad to it, but it’s a little confusing that it’s not a subtype of Object, and every nullable type T? (including Object?, a top type) is actually T & Null. Dart doesn’t even support intersection types!5

pint° intends to have a simpler approach by having a single canonical singleton type (let’s call it () for now), and by having it as a subtype of . Optionality is then not represented by an intersection type but by a proper sum type Option(T).6

Considering interoperability heuristics, we will have to make some considerations. Here is more-or-less a table of equivalencies.

Dartpint°
Object?
dynamic
void, in a covariant position()
void, in a contravariant position
Object,Some(⊤)7
Null()
T & Null (T?)Option(T)
T, when bound to ObjectSome(T)

A word on records #

I have the idea to remove the wall between function parameters and records in pint°. By doing so, every function will have a single parameter, and functions with many parameters will be represented by functions with a record as a parameter.

I’m not completely sold on this idea, but it’s something that I would like to experiment, and it allows for interesting expressiveness, as you can build function parameters dynamically before passing them to the functions. It would be even more interesting with additional operations for the record type, like record spread and record built-in with.

Doing so has its intrinsic challenges. As we have Dart as our target, we have to consider its type system when designing ours. In Dart, parameters can have default values. If we go to this route of records isomorphic to arguments, probably have to introduce some type of dependent types

For instance:

flowchart BT Def["(String name, Default(int, 18) age)"] Non["(String name)"] Non2["(String name, int age)"] Non --> Def Non2 --> Def

This is just a strawman for the idea proposed here. If we have both (String, int) and (String) to be a subtype of (String, Default(int, _)), then our type system is sound, and by having the default value encoded into the type allows us to compile it to an equivalent Dart code.

What’s next? #

I actually don’t know what I’m going to do next. Some things come to my mind:

  • Tests: the language has 0 tests. I actually don’t know if I’m going to take this as the next step, as the language is ever-changing at all levels. Also, writing tests is boring;
  • Some tooling: even if the language is small, I think it should have proper ergonomics from the start. I’m thinking of making a LSP server for the language, and an extension for VSCode to connect to this language server. Also, I would be surely tweaking more the resolver to have a more useful analysis;
  • Some features: what I want to do the most is to keep designing the language itself. There are many things to do before it can be a Turing-complete language able to be fully integrated into a Dart project, and I have many ideas for it.

If you are interested in following the project, follow the GitHub repository and the RSS for this topic in this blog, and if you want to contribute to the language, I’m fully open to discussing ideas and reviewing MRs.


  1. For type definitions, type parameters can’t be omitted. In Dart, omitted type parameters will either try to infer it from context, which is impossible in this case, or fallback to dynamic. I could make omitted type parameters fallback to , but I won’t do that, at least for now. Another alternative would be to apply it partially and return a Type → Type↩︎

  2. Currently, there’s no distinct internal representation for optional types, and some workarounds exist to identify them. The idea, however, is to introduce a proper optional type into the language, which will still be compiled into a nullable type in Dart. ↩︎

  3. Don’t mix up the verum symbol () with the capital letter T. Depending on the font, they can be very similar. The verum symbol, also called tee or up tack, represents the truth value in logic, or the top type in type theory↩︎

  4. Dart currently has two singleton types by default, Null and (). You can also define your own singleton types with regular classes. ↩︎

  5. Dart has two built-in intersection types: Object?, which is Object & Null, and FutureOr<T>, which is Future & T. I’m considering if I want to support user-defined intersection types in pint°. It would be surely interesting, and also a challenge to compile it to Dart, although not impossible. ↩︎

  6. Don’t worry, I intend to provide all the ergonomics inherent to Darts nullable types, so it becomes easy to work with Option(T), and even more. ↩︎

  7. This is not quite true, as Some(⊤) allows for Some(Option(⊤)), which is isomorphic to Option(⊤). I’m looking for a way to close this gap statically (within the type system). There are many ways to do it, but some of them would lead to more confusion. One clean but complex way to do this is to have a dependent type system that provides a constraint to Some(T) so that T would never be None. This may be a thing in the future, as I may want to introduce a kind of dependent type for dealing with default values on records. ↩︎

Mateus Felipe C. C. Pinto
Author
Mateus Felipe C. C. Pinto
Programmer since 2006, I’ve been exploring many kinds of technologies and languages. Currently, I work mainly with Flutter, in which I’ve specialized, but I can adapt to anything without much hassle. I’m also interested in compilers, PLT, game development and theology.

comments powered by Disqus