Manipulating types

Manipulating types

> LeekScript Tutorial

This article builds on knowledge of expressions, variables and functions. Its only aim is to offer one more tool for solving problems. Since LeekScript has no native annotation tools (like most dynamically typed languages), everyone will tend to have their own syntax.

Object and type

As a reminder, objects are simply things the language can manipulate: we can assign them to variables, pass them as arguments, return them from functions, and use them in expressions. In LeekScript, we have access to the following basic objects:

As you can see, these objects weren't grouped together at random. Each object belongs to a family, which we'll call a type.

Notation

Concrete types

So each object has a type, and to be able to reason with them, we'll need a way to write them down. In this article, we'll use the following notation: object : Type, with the name of the type starting with a capital letter. You're free to use another notation system. Here's the list of the most basic types, along with a few possible values:

null : Null true, false : Bool 1, -273 : Int 1.0, 2.34 : Float "a", "abc" : String

The names used here are arbitrary. This isn't a convention used by the Leek Wars community. You can also group the objects that belong to Int and Float into a single category, Num, since you very rarely need to tell them apart when manipulating them.

Note that LeekScript doesn't support type annotations: you'll have to make do with putting them in comments if you want to keep track of them.

// object : Type object; object; // : Type

Type variables

This notation is used to represent the possibility that an object has any type. It's written the same way, but with a lowercase letter at the start of the name. object : a: here, we don't know what the type of object is. It could be anything. If we define otherObject : b, we don't know what its type is either; it could even be the same type as object. However, if we define sameObject : a, we have a little more information. We still don't know the exact type of sameObject, but we do know that it's the same as the type of object. For example, if object : Num, then sameObject is also of type Num. otherObject could be of type Num, but it could also be of type String or Bool, etc.

Parameterized types

These are slightly more complex types. You've probably wondered why arrays and functions weren't introduced earlier. You could say that, in a way, they contain other types. For example, [1] contains Num, and function(x) { return "" + x; } takes anything (a) and returns it as a string (String).

To annotate these types, we use a concrete type followed by other concrete or variable types. For example, an array of numbers can be annotated like this: array : Array Num, and an array of strings like this: array : Array String. The function that took any object and returned its string representation can be annotated: toString : Function a String. So it's possible to have several types as arguments. Maps are another example of this. For instance, a map that indicates whether cells are out of an opponent's sight can be annotated: safeCells : Assoc Cell Bool.

From now on, when we write down arrays or functions, we'll use a lighter syntax:

array : [Num] toString : a -> String safeCells : {Cell : Bool}

For functions that take no arguments, we'll use (). You can also use it, as a matter of principle, for what functions return, but in LeekScript any function that returns nothing implicitly returns null. For example, rand : () -> Float, say : String -> () or say : String -> Null.

When we want to annotate an object that can have several types, all of which we know, we'll use the following notation: a | b | ... Of course, there can be more than two alternatives. Another way to annotate it would be Sum a b .... An example of this kind of type is what getNearestEnemy returns. If no enemy is alive, or if they're all on unreachable cells, this function returns null instead of an id: getNearestEnemy : () -> ID | Null.

When we want to annotate an object that contains several objects of different types, we'll use (a, b, ...), which would correspond to Product a b .... This type is usually called a tuple, or n-tuple. It's mostly useful when we want to annotate functions that take several arguments. In LeekScript 2, it should be possible to return several objects at once. For now, we have to make do with arrays of type [a | b | ...], or get clever with closures. An example of a function that takes several arguments: lineOfSight : (Cell, Cell) -> Bool.

Type synonyms

When we annotated safeCells, we used Cell. This type doesn't exist in LeekScript: it's actually an Int. However, using Cell makes it clearer what safeCells contains. safeCells : {Int : Bool} is still a valid annotation, though. If you picture a map containing the leeks' ids and their positions on the map, {ID : Cell} is much easier to work with than {Int : Int}. In the same way, we can consider that Num = Int | Float

Precedence

When we annotate complex types, we can use parentheses to avoid ambiguity. As for functions, because of how they are evaluated, precedence is on the left. So a function that takes a function as a parameter is written (a -> b) -> (), but a function that returns a function is written () -> (a -> b), which can be simplified to () -> a -> b.

Documentation notation

The documentation uses a different notation for the types of its functions. Here are a few examples, in the documentation's notation and in this article's:

getCellsToUseChip(Number chip, Number leek) : ArrayOfNumbers cells arrayFilter(Array array, Function callback) : Array newArray getLife(Number leek) : Number life say(String message)

getCellsToUseChip : (Chip, ID) -> [Cell] arrayFilter : ([a], a -> Bool) -> [a] getLife : ID -> Num say : String -> ()

Type of an expression

So far, we've only looked at the type of an object. However, what we're interested in is manipulating these objects to get useful results for our programs. So we can annotate the types of the expressions that make up our program. When you read 1 + 1, you immediately think of the result: it's a number, 1 + 1 : Num. As a reminder, an object on its own is also an expression (1 : Num), but it can't be evaluated (reduced) any further. Annotating the result of an expression can be useful, but when expressions get complex, or when there's a mistake, our annotation may be wrong. To make sure our type is valid, we'll break our expression down, give each element an obvious type, then substitute these types where needed to find the type of our expression once it's reduced. So, in the case of 1 + 1, we have three elements:

We can safely say that the objects passed to (+) are indeed of the required type. If we spell out the substitution steps, we can write them as follows:

Note that the second-to-last step isn't really useful, and the one before it can be skipped. They're shown here to make the disappearance of the arguments explicit.

With a slightly more complex expression, here getting all the cells from which we can target our enemies, duplicates included:

arrayFlatten(arrayMap(getAliveEnemies(), getCellsToUseWeapon), 1) arrayFlatten : (a, 1) -> [a] arrayMap : ([a], a -> b) -> [b] getAliveEnemies : () -> [ID] getCellsToUseWeapon : ID -> [Cell]

getAliveEnemies() : [ID] arrayMap(_, getCellsToUseWeapon) : ([ID],) -> Cell arrayMap(getAliveEnemies(), getCellsToUseWeapon) : Cell arrayFlatten(arrayMap(getAliveEnemies(), getCellsToUseWeapon), 1) : [Cell]

arrayFlatten behaves a little unusually. By default, when it's given only one argument, it ignores the sub-levels and flattens everything. Unfortunately, that's very rarely useful. (Never, in the author's case.) It also makes the program's behavior harder to understand. Because of this, we only use one level of concatenation and make it explicit in the type. If we want to use two levels of concatenation, we'll simply write arrayFlatten : ([a], 2) -> [a])

Expressions from a type

Checking that what we do is correct is the compiler's job. Where type manipulation can help us is by guiding us when we build new expressions. By knowing the types of what we have available and the types of what we want to get, we can greatly narrow down where to look for the solution. For example, if we want to reduce a list of numbers to a single number, the standard library gives us the following functions, with the matching types:

average : [Num] -> Num sum : [Num] -> Num

count : [a] -> Num

arrayMax : [a] -> a arrayMin : [a] -> a pop : [a] -> a shift : [a] -> a

However, we'll rarely be able to find a function that does exactly what we want right away. Depending on the data available, we can insert an intermediate step. Assuming an input of type a and an output of type b, a good starting point is to list the functions and operators that take a as input or return b as output. Then all that's left is to find the pairs of functions whose types match and, if none of them do, to look further by adding a level of indirection. Depending on the number of inputs or outputs available, some types may only turn out to be useful for certain indirections.

Here's an example of a type to solve that came up a while before this article was written:

getLeekNamed : (String, ID) -> ID | Null function getLeekNamed(name, potentialID) { /* ... */ }

The goal was to return the id if the name belonging to it was the same as the one passed as a parameter, and otherwise to return null (no return value). The next step would then have been to transform the function so that it could be applied to a list of ids. ((String, [ID]) -> ID | Null) So first solving an expression whose type we've simplified then lets us solve the real problem fairly easily, by adding the mechanism for handling the parameterized type. Here, going through an array.

Going further

Constrained types

The (+) operator was annotated above as only working on Num. However, you've probably used it for other types, such as String and []. If you picture numbers as being represented in unary form (which doesn't really hold up with decimal numbers, but just play along), then addition becomes concatenation, just like for strings and arrays. This concatenation operation can be abstracted as a class, Concatenable. With this new notation, we can now generalize the type of (+) : Concatenable a => (a, a) -> a. There are other operators and functions that work this way. In particular, the comparison operators, which require a type that can be ordered (Ord), and the equality operators, which require a type for which we can check whether values are equal or not (Eq).

After seeing the type of (+), you're probably wondering why it's possible to do something like 1 + " Nowhere Street" or null + [1, 2], and how to represent that in the type. We won't. When the types don't match, either we get an error, or the objects are automatically converted to strings before the concatenation is performed.