Defining a public API for your Dart package

Stephan E.G. Veenstra's photo
Stephan E.G. Veenstra
·Jun 27, 2022·

10 min read

Defining a public API for your Dart package

Table of contents

When building a Dart package you'll have to think about which information and functionality you want to expose to the consumer. You don't want to expose too little since it can make your package unusable. On the other hand you also don't want to expose too much and make the package confusing to use.

This article is the 3rd part of the Introduction to TDD series in which we'll create a package containing the logic for Tic Tac Toe, using Test Driven Development.

The public API and TDD

The reason why the public API is so important for TDD is because the public API is exactly what we will be covering with our tests. By covering the public API we ensure that the API works as intended.

We could skip this step of designing our API and let TDD guide us. However, I like to think about the API upfront because I think it's most important that the API is easy to use, since it's going to be used by developers with a variety of skill. So when I use TDD I most often use it for the implementation. Note that our design is not set in stone and (minor) changes can come up, and that's okay!

Once you have a fully covered API you can make changes and improvements in the future with full confidence.

Creating a package project

We will start by creating a new Dart package.

There is a great chance that you're reading this because you are or want to become a Flutter developer. But for this package we do not need to depend on the Flutter framework because our package will only contain logic.

By not depending on the Flutter framework we are not limiting our package to only be used in Flutter projects. This means that our package can also be used on CLI or server projects, cool!

So let's go ahead and create a Dart package. Give it a descriptive name. I'll be calling it tic_tac_toe_game.

Creating the game state file

We will need a file to put our game state code. Let's call the file tic_tac_toe_game_state.dart and put it under lib/src. We'll leave it empty for now. This file will contain the class that will represent the current state of the game.

It's common to put your files under the src directory. Dart will hide it for the consumer to discourage them from being used directly.

Exposing our API

We have control over which files we want to expose to the consumer by exporting them in the library-file. This file should be available under lib/<your package name>.dart since it is generated when creating a package.

So for me this file is called lib/tic_tac_toe_game.dart and the contents look like this:

/// Play Tic Tac Toe
///
/// Use this package to create a Tic Tac Toe Game.
library tic_tac_toe_game;

// exports go here

It contains the library name and some information about the library. On the bottom we can add our exports for the files we want to make available for the consumer. Everything that's exported here can be seen as the public API. So let's add our previously created file to it to make it available:

/// Play Tic Tac Toe
///
/// Use this package to create a Tic Tac Toe Game.
library tic_tac_toe_game;

export 'src/tic_tac_toe_game_state.dart';

Defining properties and functions

The file lib/src/tic_tac_toe_game_state.dart will contain the class that allows the consumer to create, and play, a game of Tic Tac Toe. So with this in mind, we can think about the properties and functions we should give it to make this possible.

Representing the current state

First of all, our consumers should be able to create a representation of the current state of the game. So if we were the consumer, what would we need from our package to make this possible?

The interface

We will start by creating the abstract class in our new file (we are using an abstract class because Dart doesn't have explicit interfaces, every class can be used as an interface by implementing it):

abstract class TicTacToeGameState {}

The playing field

In the previous part of these series, we've written down the rules of the game. The game is played on a three-by-three grid. This grid exists of nine fields that can either be empty, occupied by player 1 or occupied by player 2.

So how would we represent this information in Dart code? I think there are two straight forward ways to do this. The first would be using an enum to represent the three states like:

// Possible states of the fields
enum Field { empty, player1, player2 };

abstract class TicTacToeGameState {
  // represent the 9 fields
  List<Field> get fields;
}

But personally, I'm not a fan of this approach. It gives me the feeling that the empty value is just as valuable as the other two, which I think is not. Also, the enum is now very specific to this information. So I would rather do this:

// Players
enum Player { one, two };

abstract class TicTacToeGameState {
  // represent the 9 fields
  List<Player?> get fields;
}

Here the enum represents the players and is not tightly coupled to the fields list. This way it is easier to be reused. Now fields is represented by a List of 'nullable' Player objects. So a field can be either empty (null) or contain a Player (Player.one or Player.two).

Using null is often discouraged because it can lead to null-pointer exceptions during runtime. However, since Dart 2.12, the language is sound null safe which means the compiler knows and warns you about nullable values. Dart being sound null safe makes null a valuable option for representing empty values.

Whose turn is it?

Another piece of useful information is knowing whose next to make a move. This information can be used by the consumer to show a message like: "Player 1, you're up!". So something like this might work:

enum Player { one, two };

abstract class TicTacToeGameState {

  List<Player?> get fields;

  // Returns the Player that can make the next move.
  // This returns null when the game is over.
  Player? get turn;
}

We are reusing the Player enum as the return type. We've made it nullable because it should not return a Player when the game is finished.

Game result

Our consumers should also be able to tell their users if the game is over, and who has won. If we check the rules that we've listed in the previous article, we'll see that the game can end in either a win for player 1, win for player 2 or a draw. So here's how to translate this into code:

enum Player { one, two };

enum Result { p1Win, p2Win, draw };

abstract class TicTacToeGameState {

  List<Player?> get fields;
  Player? get turn;

  // Returns the current result state.
  // Is null when the game is not over.
  Result? get result;
}

As you can see I've made this return type of Result also nullable. That's because we only have a result when the game is over. While the game is still being played, this should return null.

With this information the consumer should be able to represent the current game state just fine, but...

Simplifying the API

We've just came up with three properties for our game state that can be used to give feedback to the players.

  1. fields can be used to build a visual representation of the occupied fields.
  2. turn can be used to show which player can make the next move.
  3. result can be used to show which player(s) won.

While this could work just fine, it might not be the most clean solution. Let's take a closer look at turn and result. They both return a nullable value, which is fine. But the problem lies with when they are returning null. Let me show you with a piece of code a consumer might write:

bool shouldShowGameOverScreen(GameState state) {
  // when turn returns null we know that the game is over.
  return state.turn == null;
}

In this example we use turn to determine if the game is over. But check out the following code:

bool shouldShowGameOverScreen(GameState state) {
  // when result is not null we know that the game is over.
  return state.result != null;
}

You see, in this case we can use result to get the same information. When turn is null, we know result is holding a value and when result is null, we know turn is holding a value. In other words, these two fields are mutually exclusive.

Together these two fields have 12 (3 from turn and 4 from result) different possible combinations of which most could never happen. But for our consumer that might not be clear right away and they might write unnecessary checks for it.

As I mentioned in the beginning of this article, giving the consumer too much information might be confusing, so let's straighten this out.

So when turn is null, result will be either Result.p1Win, Result.p2Win or Result.draw. When result is null, turn will be either Player.one or Player.two. Of the 12 possible combinations, only 5 are actually relevant. So the way to improve this is by combining the two fields like this:

enum Player { one, two }

enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }

abstract class TicTacToeGameState {

  List<Player?> get fields;
  Status get status;
}

Now the consumer only has one property to worry about. This makes it not only easier for them to use it, but it also makes it easier for us to test. Remember that when we are doing TDD we will test the public API. We now have less cases to test, thus saving us some work.

I don't know about you, but I'm pretty happy with the result!

Playing the game

We found a way to represent the game state, but we're missing a way to change it. We are going to define a function that can be used to play the game.

The game is played by claiming fields. So we need to come up with a function that can be used for this. Let's call the function claimField:

enum Player { one, two }

enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }

abstract class TicTacToeGameState {

  List<Player?> get fields;
  Status get status;

  ????? claimField(????);
}

Parameters

There are nine fields to be claimed, so our function needs to know which of these is being claimed. We can simply use an int for this, let's add it to our function:

enum Player { one, two }

enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }

abstract class TicTacToeGameState {

  List<Player?> get fields;
  Status get status;

  ????? claimField(int index);
}

We also need to know who is doing the claiming so we can assign the field to the correct Player. We could add another parameter of type Player so the consumer can pass the Player that is doing the claiming. But we are not going to do this, and I'll explain why.

The status property can already tell us who's turn it is. So we can assume the consumer checks this property before calling our function. If we do that we can also use it to determine who is doing the claiming by simply checking who's turn it is.

Again we've limited the amount of errors by giving the consumer just enough to work with. If we did add the player parameter we would have to start handling cases where the wrong player is being passed.

Return type

Our game state object is going to be immutable. This means the properties, fields and status, will never change. Instead, we will return a new game state every time a field is claimed.

Immutable objects are way easier to work with and reason about than mutable objects. They are also way safer to work with because you know that whenever you check a property, it will always be the same.

So with this knowledge we can complete the signature of our function:

enum Player { one, two }

enum Status { p1Turn, p2Turn, p1Win, p2Win, draw }

abstract class TicTacToeGameState {

  List<Player?> get fields;
  Status get status;

  TicTacToeGameState claimField(int index);
}

And that's it! This is everything we need for now.

What's next?

Now that we've designed a public API for our package it's time to start implementing. In the next part we will start writing our first tests and implementation.

Next article: Writing your first test in Dart

 
Share this