Types can accept parameters, akin to generics in other languages. It's as if a type is a function that takes in arguments and returns a new type! The parameters need to start with '
.
The use-case of a parameterized type is to kill duplications. Before:
/* this is a tuple of 3 items, explained next */
type intCoordinates = (int, int, int);
type floatCoordinates = (float, float, float);
let buddy: intCoordinates = (10, 20, 20);
After:
type coordinates 'a = ('a, 'a, 'a);
/* apply the coordinates "type function" and return the type (int, int, int) */
type intCoordinatesAlias = coordinates int;
let buddy: intCoordinatesAlias = (10, 20, 20);
/* or, more commonly, write it inline */
let buddy: coordinates float = (10.5, 20.5, 20.5);
In practice, types are inferred for you. So the more concise version of the above example would be nothing but:
let buddy = (10, 20, 20);
The type system infers that it's a (int, int, int)
. Nothing else needed to be written down.
Type arguments appear everywhere.
/* inferred as `list string` */
let greetings = ["hello", "world", "how are you"];
If types didn't accept parameters (aka, if we didn't have "type functions"), the standard library will need to define the types listOfString
, listOfInt
, listOfTuplesOfInt
, etc.
Types can receive more arguments, and be composable.
type result 'a 'b =
| Ok 'a
| Error 'b;
type myPayload = {data: string};
type myPayloadResults 'errorType = list (result myPayload 'errorType);
let payloadResults: myPayloadResults string = [
Ok {data: "hi"},
Ok {data: "bye"},
Error "Something wrong happened!"
];
Just like functions, types can be mutually recursive through and
:
type student = {taughtBy: teacher}
and teacher = {students: list student};
Note that there's no semicolon ending the first line and no type
on the second line.
A type system allowing type argument is basically allowing type-level functions. list int
is really the list
type function taking in the int
type, and returning the final, concrete type you'd use in some places. You might have noticed that in other languages, this is more or less called "generics". For example, ArrayList<Integer>
in Java.
The principle of least power applies when you're trying to "Get Things Done". If the problem domain allows, definitely pick the least abstract (aka, the most concrete) solution available, so that the solution is reached faster and has fewer unstable indirections you'd have to traverse. For example, prefer types over free-form data, prefer data-driven configuration over turing-complete function calls, prefer function calls over macros, prefer macros over project forks, etc. When you constraint your domain and power, things become easier to analyze. That is, if the domain is constrained enough to allow it.
When a type system is an all-encompassing aspect of your program, we need to make sure we leave enough power order not to overly constrain your expressiveness; without "type functions", you'd end up with quite a bit of boilerplate, e.g. hard-coded listOfInt
, listOfString
, listOfArrayOfFloat
, their respective helper functions, etc. However, please also make sure you don't overly abuse the power given to you through a rather powerful type system. Sometimes, it's fine to write a little bit of boilerplate to reduce the need for otherwise extra powerful types. If anything, tasteful tradeoffs might show your pragmatism and judgement more than fancy types!