Meme via u/BenSteamRoller
The Monkey C language was introduced in 2014 as an object-oriented, memory-managed, duck-typed language. While it has a robust class system, the compiler did not perform any compile time type checking of the code. This allows for very flexible code, but also makes it difficult to identify type issues until run time. Every Connect IQ developer is familiar and annoyed with the Symbol Not Found error, which happens when you attempt to access a value not present in an object. Connect IQ 4 introduces Monkey Types, a new type system for Monkey C. Monkey Types has four levels of type-check, from none to strict, to allow developers to choose their style of programming.
In this piece we will take you down the journey of taking a watch face from duck typing to strict typing. Along the way we will introduce the new syntax you can use to type your applications.
The Four Levels of Type Safety
A compiler type checker is inherently the judgiest of software. Its job is to tell you why it can’t understand your beautiful musings. It is computer software where you can set how pedantic you want it to be.
As stated above, Monkey Types provides four levels of type-checking:
Level
|
Description
|
Judginess
|
Work
|
None
|
Default. Same checks as performed today
|
Supes-chill dude. Does it compile? Then I’m sure it runs!
|
Does your code compile? Then you don’t have any work to do
|
Gradual
|
Type-checker will only check type safety when interacting with typed code.
|
Assumes you’re a good student. Will highlight errors it sees, but assumes anything it can’t understand is fine
|
Requires adding some clarity to calls, but in an hour a watch face can be converted to compile at this level
|
Informative
|
Type-checker will only check type safety when interacting with typed code but will warn you about untyped code.
|
More passive aggressive than judgy. Points out everything it can’t check but ends with “but if you say it’s okay then who am I to judge”. Is probably sub-tweeting about you.
|
Same as Gradual, but with more drama.
|
Strict
|
Type-checker expects all definitions to be typed.
|
Pretty judgy. Gets upset if it can’t check your code.
|
For a new code base, strict typing requires minimal effort and encourages defensive coding. For an existing code base, it can take some effort to convert to strict typing.
|
Gradual Type-Checking
Let’s start with a very simple watch face:
using Toybox.WatchUi;
using Toybox.Graphics;
using Toybox.System;
using Toybox.Lang;
class TypeCheckFaceView extends WatchUi.WatchFace {
function initialize() {
WatchFace.initialize();
}
// Load your resources here
function onLayout(dc) {
setLayout(Rez.Layouts.WatchFace(dc));
}
// Called when this View is brought to the foreground. Restore
// the state of this View and prepare it to be shown. This includes
// loading resources into memory.
function onShow() {
}
// Update the view
function onUpdate(dc) {
// Get and show the current time
var clockTime = System.getClockTime();
var timeString = Lang.format("$1$:$2$", [clockTime.hour, clockTime.min.format("%02d")]);
var view = View.findDrawableById("TimeLabel");
view.setText(timeString);
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
}
}
This is the standard template for a new watch face and compiles perfectly fine with no type checking. Now we will turn up the type-check level in Visual Studio Code by going to the extension settings and setting the type check level:
Now when we build the current project, we have a type error on the following lines:
ERROR: fenix5plus: TypeCheckFace\source\TypeCheckFaceApp.mc:19: Object of type '$.Toybox.Lang.Array<Any>' does not match return type 'PolyType<Null or $.Toybox.Lang.Array<$.Toybox.WatchUi.InputDelegate or $.Toybox.WatchUi.View>>'.
ERROR: fenix5plus: TypeCheckFace\source\TypeCheckFaceView.mc:29: Cannot find symbol ':setText' on type 'PolyType<Null or $.Toybox.WatchUi.Drawable>'.
At this point you might say “wait, you told me that gradual typing only type checks typed code. I haven’t added any types, so why is it flagging errors?”. That is a good question, and can be answered with three points:
- While you haven’t added any typing to your code, we added type information to the Monkey C API.
- Monkey Types type inferencing of local variables allows it to track types on API calls return values.
- Monkey Types will auto-apply the typing from a parent class to overridden functions of a subclass.
Even though you haven’t decorated your code with type information, Monkey Types sees that you’ve extended WatchUi.WatchFace and you’ve overridden View.onUpdate, and it has some things to say about that.
So how do we clear this up? It’s time to introduce the first two concepts of Monkey Types: casting and typed containers.
Casting Types
In our TypeCheckFaceView.onUpdate() function Monkey Types can detect we are calling View.findDrawableById() which returns a value of type WatchUi.Drawable. However, WatchUi.Drawable does not have a function named setText(); that belongs to WatchUi.Text. We know that view is a Text, so let’s clear that up for the type checker:
var view = View.findDrawableById("TimeLabel") as WatchUi.Text;
Because of its duck-typed nature casting in Monkey C before Monkey Types was unnecessary, but now we need to a way to communicate these kinds of misunderstandings to the type system. The as keyword is the new keyword for both type application and type casting. Within a function the as keyword behaves like a casting operator, with the value to cast on the left and the type to cast it to on the right.
At this time type casts, are not type-checked at compile time. Type casts allow you to tell the type checker that you know better, but in the future there will be compile time type cast checking.
Typed Containers
Monkey Types now allows container types to be typed. You can use the following syntax to create a new typed Array or Dictionary:
Syntax
|
Description
|
var a = [1, 2, 3] as Lang.Array<Lang.Number>
|
a is an Array that only accepts values Number types.
|
var d = { “key”=>”value” } as Lang.Dictionary<Lang.String, Lang.String>;
|
d is a Dictionary that only accepts keys of type String and assigns values of type String.
|
var s = new Lang.Array<Lang.String or Null>[10];
|
s is a ten element Array that only accepts values of type String.
|
Adding a type with as at container declaration is different than casting a value. When you add a type to an Array or Dictionary declaration, the type checker will validate all assignments to the container. If you apply the as clause to the local, it is a type cast. Here is a disambiguation:
Typed Container
|
Type Cast
|
Type Casting
|
var a = [] as Lang.Array<Lang.String>;
|
var a = []; return a as Lang.Array<Lang.String>
|
|
By this point you might be thinking “wow those module prefixes are going to get super annoying”. Don’t worry, we thought that too, and we will get to that later.
The problem we have is that in our application class AppBase.getInitialValue() now expects it’s return type to be a typed array. We can easily change the declaration, but we must add the new modules we are referencing to the using statements:
using Toybox.Application;
using Toybox.WatchUi;
using Toybox.Lang;
class TypeCheckFaceApp extends Application.AppBase {
function initialize() {
AppBase.initialize();
}
// onStart() is called on application start up
function onStart(state) {
}
// onStop() is called when your application is exiting
function onStop(state) {
}
// Return the initial view of your application here
function getInitialView() {
return [ new TypeCheckFaceView() ] as Lang.Array<WatchUi.View or WatchUi.InputDelegate>;
}
}
With those changes, we now pass gradual type checking.
It’s good to review here what the type checker knows and doesn’t know. By default, any local or function parameter is of type Any. The Any type is the traditional Monkey C value type. It can be anything, or it can be nothing (null). At the gradual level, the type checker will not attempt to type check calls against values of type Any. Instead it will check the things it can infer:
- The result of a new operation
- The result of creating a container
- The return value of a Toybox module function
- A typed function parameter, either explicitly type or type-inferred from the function it overrides
Within your onUpdate() function, any call you make to dc will be type checked, catching typos and type issues that would before would require a test run to find. Because the API is typed this covers a lot of ground but can also leave big gaps in what can be checked. For many projects this is a perfectly acceptable level of type checking; it allows you to check calls against the Toybox API without forcing you to refactor the code.
But how big is the type check gap? What does it not know?
Informative Typing
Informative typing will issue the same errors as gradual typing, but will warn about any untyped member variable, function argument, or return value.
When we up the type-check level to informative on our watch face, we get the same output as before. This is because all our functions override typed function definitions, and we don’t have any member variables. However, doing a findDrawableById() on every update is a little slow. We should cache that value in onLayout() so we only have to do the lookup once:
using Toybox.WatchUi;
using Toybox.Graphics;
using Toybox.System;
using Toybox.Lang;
class TypeCheckFaceView extends WatchUi.WatchFace {
private var _timeLabel;
function initialize() {
WatchFace.initialize();
}
// Load your resources here
function onLayout(dc) {
setLayout(Rez.Layouts.WatchFace(dc));
_timeLabel = View.findDrawableById("TimeLabel");
}
// Called when this View is brought to the foreground. Restore
// the state of this View and prepare it to be shown. This includes
// loading resources into memory.
function onShow() {
}
// Update the view
function onUpdate(dc) {
// Get and show the current time
var clockTime = System.getClockTime();
var timeString = Lang.format("$1$:$2$", [clockTime.hour, clockTime.min.format("%02d")]);
_timeLabel.setText(timeString);
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
}
}
If we compile this at the informative level, we get the following:
WARNING: fenix5plus: source\TypeCheckFaceView.mc:7: Member '$.TypeCheckFaceView._timeLabel' is untyped.
This may seem like the type checker is stating the obvious. Yes, the value is untyped, but why can’t it just infer the value from the assignment like it magically did for locals? The challenge with a member variable is that it can be assigned within any function inside (or potentially outside) the class, and tracing all potential assignments is a level of static analysis the compiler is not going to attempt. What the compiler is saying is “you didn’t type this, so I won’t be able to validate any reference you make to this value. If you’re cool with that, I’m cool with it”.
You can use the as keyword (hi again!) to add type information to member declarations:
Syntax
|
Description
|
var a as String or Null;
|
a is a member variable that only accepts values of type String or null
|
function example(x as String) as Void
|
example() is a function that takes a parameter x which must be a String. It has no return value
|
function getValue() as String?
|
getValue() is a function that returns either a String or null.
|
Let’s recompile with the following change:
private var _timeLabel as WatchUi.Text;
Don’t forget to add the cast to the assignment of _timeLabel in onLayout(). When we compile we now get the following:
ERROR: fenix5plus: source\TypeCheckFaceView.mc:7: Member _timeLabel not initialized but does not accept Null.
Monkey Types requires you to declare if a member variable can be null. If it can’t be null, you must initialize the value at declaration or in the initialize function. In our case we can’t initialize _timeLabel until onLayout(), so we have to allow it to be null. With a single type declaration, we can allow null by adding a question mark:
private var _timeLabel as WatchUi.Text?;
Now the compiler is happy once again.
Import Statements
As you start adding typing to your functions and variables, you will quickly tire of adding all the module prefixes. We didn’t want to change the behavior of using statements, so Monkey Types introduces the new import statement. For functional code import works the same as using, but for type scaffolding import brings the class definitions of the module into the namespace. If we change our using statements to import statements:
import Toybox.WatchUi;
import Toybox.Graphics;
import Toybox.System;
import Toybox.Lang;
We can now define _timeLabel as:
private var _timeLabel as Text?;
One big difference between import and using statements is that import doesn’t allow renaming the module using as. This is an intentional change; module renaming has negative impacts on code readability as common modules can be renamed per file.
Strict Typing
With strict typing, the compiler requires all member variables, function parameters, and function return values to be typed. At this point you may say “not every part of my code can be typed as a set of defined classes, and it will take some refactoring to squeeze it into that definition”. Thankfully Monkey Types has a whole new set of typing tools to assist:
Type
|
Description
|
Syntax
|
Example
|
Concrete Types
|
Any class in the code namespace
|
<Class Name>
|
var x as Number;
|
Void
|
Only allowed as a return value, communicates that function does not return a value
|
Void
|
function doIt() as Void
|
Poly Types
|
A list of n allowed types a value accepts
|
Type or Type
|
var x as Number or String;
|
Interface
|
A value must include the listed definitions to match. All definitions are public.
|
interface {
var <name> (as Type); function <name>(<arguments>);
}
|
function doIt(x as interface { function whatToDo(); })
|
Container
|
A typed Array or Dictionary
|
Array<Type> Dictionary<Type, Type>
|
var x as Array<Number>;
|
Dictionary
|
Type checks the values a dictionary accepts to a certain set of keys
|
{
[<literal>=>Type],
}
|
function doIt(options as {
:key1=>String,
:key2=>Number
})
|
Enumerations
|
A named enum definition
|
enum <Name> {
}
|
enum Values {
VALUE_1,
VALUE_2
};
|
Callback
|
A typed Method
|
Method(<arguments>) [as <Type>]
|
var x as Method(a as String) as Void;
|
Null
|
Accepts a null value
|
Null
|
var x as String or Null;
|
Named Types
|
Allows you to give a namespace definition to a commonly used type
|
typedef <Name> as Type;
|
typedef NumberArray as Array<Number>
|
You can read more about the types in the Monkey Types section of the documentation. Do you have a set of classes that fit into an interface pattern? Now you can define an interface it has to match. Do you use Method objects? Now they can be type checked. Do you have enumerations you pass as parameters? Now they can be type checked.
Monkey Types only exist at compile and do not add any runtime baggage to your app. While this means you aren’t penalized for typing your app, it also means that instanceof and has still only function with class definitions and symbols, respectively.
Defensive Coding
Let’s say we wanted to add the current heart rate to this watch face. Rather than add a layout, we’re going to add a draw call. Let’s create a function to draw the heart rate at the top of the screen:
function drawHeartRate(dc as Dc, heartRate as Number) as Void {
dc.drawText(dc.getWidth() / 2, 0, Graphics.FONT_SMALL, heartRate.toString(), Graphics.TEXT_JUSTIFY_CENTER);
}
Now let’s add something at the tail end of our onUpdate() (don’t forget to import Toybox.Activity):
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
var hr = Activity.getActivityInfo().currentHeartRate;
drawHeartRate(dc, hr);
When we compile we get the following:
ERROR: fenix5plus: TypeCheckFace\source\TypeCheckFaceView.mc:37: Passing 'PolyType<Null or $.Toybox.Lang.Number>' as parameter 2 of non-poly type '$.Toybox.Lang.Number'.
The value ActivityInfo.currentHeartRate value can be a Number or it can be null, but our function only accepts Number. The compiler is letting us know that we aren’t handling this case. To fix this we could use a type cast, but that’s not really fixing the fundamental issue. Instead lets change the onUpdate() code to the following:
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
var hr = Activity.getActivityInfo().currentHeartRate;
if (hr != null) {
drawHeartRate(dc, hr);
}
And the compiler is now happy. This is an example of an if-split. With local variables, the compiler will examine the if expression for a type test and temporarily re-type a value in the true and false case. You can also do the following:
View.onUpdate(dc);
var hr = Activity.getActivityInfo().currentHeartRate;
if (hr instanceof Number) {
drawHeartRate(dc, hr);
}
With Monkey Types the compiler is much more aggressive in telling you where you have potential type and null pointer issues and encourages you to fix it with more defensive code. At the strict type check level, it can validate all your functional code and the calls to the API.
Let’s take a final look at our watch face view class with all the typing information:
import Toybox.Activity;
import Toybox.WatchUi;
import Toybox.Graphics;
import Toybox.System;
import Toybox.Lang;
class TypeCheckFaceView extends WatchUi.WatchFace {
private var _timeLabel as WatchUi.Text?;
function initialize() {
WatchFace.initialize();
}
// Load your resources here
function onLayout(dc as Dc) as Void {
setLayout(Rez.Layouts.WatchFace(dc));
_timeLabel = View.findDrawableById("TimeLabel") as WatchUi.Text;
}
// Called when this View is brought to the foreground. Restore
// the state of this View and prepare it to be shown. This includes
// loading resources into memory.
function onShow() as Dc {
}
// Update the view
function onUpdate(dc as Dc) as Void {
// Get and show the current time
var clockTime = System.getClockTime();
var timeString = Lang.format("$1$:$2$", [clockTime.hour, clockTime.min.format("%02d")]);
_timeLabel.setText(timeString);
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
var hr = Activity.getActivityInfo().currentHeartRate;
if (hr instanceof Number) {
drawHeartRate(dc, hr);
}
}
// Draw the heart rate
function drawHeartRate(dc as Dc, heartRate as Number) as Void {
dc.drawText(dc.getWidth() / 2, 0, Graphics.FONT_SMALL, heartRate.toString(), Graphics.TEXT_JUSTIFY_CENTER);
}
}
Which Type Level Makes Sense?
It is not a requirement to enable Monkey Types. The gradual type level is intended for bringing the advantages of type checking with minimal effort to an existing code base and is effective at finding many type errors with little additional type scaffolding. If you have an existing project that has achieved code stability and you mostly add support for new devices, the cost of getting it to compile under strict type check may not be worth it, but if you begin a project at the strict typing level, it is straightforward to add the type definitions as you go, and the compiler will keep you honest.