Preconditions and TryFunctions in AL

TryFunctions in AL deservers more attention. They can play an important role in making your code more safe, robust and easier to read.

A little bit of Theory and Terminology

A function f maps a domain A to its codomain B.

\[f: A \rightarrow B\]

The function f taks an argument a and returns a value b.

\[f(a) = b\]

This article is about ensuring that the argument a is within the domain A of the function f before running the function. This is a precondition for running the function.

Automated Testing is about ensuring that the value b corresponds with the expected mapping into the codomain B.

Checking Preconditions

Before looking at the TryFunction, let us look at a simple case of ensuring that the preconditions for running the function are met.

We have been asked to make an average function given a total and a number of lines.

That is easy, we think and write the following.

procedure Average(Total: Decimal; NumberOfLines: Integer): Decimal
begin
    exit(Total / NumberOfLines);
end;

This is an example of code that is not robust.

Except that we are not checking the preconditions. The preconditions for this function is that the NumberOfLines is a positive number. I often write the precondition check in the following format in the start of the function:

    if not (Precondition) then
        exit();

Like this:

procedure Average(Total: Decimal; NumberOfLines: Integer): Decimal
begin
    if not (NumberOfLines > 0) then
        exit(0);

    exit(Total / NumberOfLines);
end;

But if a developer calls this function with a negative number of lines, something is wrong. So I want to insist harder on the valid domain for the function, and I throw an error.

procedure Average(Total: Decimal; NumberOfLines: Integer): Decimal
var
    ErrorLbl: Label 'Number of Lines cannot be a negative number.';
begin
    if (NumberOfLines < 0) then
        Error(ErrorLbl);
    if (NumberOfLines = 0) then
        exit(0);

    exit(Total / NumberOfLines);
end;

I could go further and check that the Total should be zero if the NumberOfLines is zero. And I could avoid the calculation and possible rounding errors when the NumberOfLines is one.

procedure Average(Total: Decimal; NumberOfLines: Integer): Decimal
begin
    if (NumberOfLines < 0) then
        Error('Number of lines cannot be a negative number.');
    if (NumberOfLines = 0) then begin
        if not (Total = 0) then
            Error('The total must be zero, when the number of lines is zero.');
        exit(0);
    end; 
    if (NumberOfLines = 1) then
        exit(Total)
    else
        exit(Total / NumberOfLines);
end;

This is an example of code that is robust.

TryFunctions in AL

Now let us consider a standard system function in AL: System.DMY2Date

Date := System.DMY2Date(Day: Integer, Month: Integer, Year: Integer);

The domain is

  • Day: Integer between 1 and 31
  • Month: Integer between 1 and 12
  • Year: Integer between 0 and 9999

But checking that each argument is within its domain is not enough to prevent the function from failing hard during runtime.

Date := System.DMY2Date(29, 2, 2019);

February 29th, 2019 is not a valid date.

Imagine that you are getting text data from an import file or an API, and you have to convert it into a date.

To protect our code, we add the TryFunction Attribute to our function. The TryFunction cannot have a return value, since it default returns a boolean. So we add var to return the calculated value NewDate as an argument.

[TryFunction]
procedure TryDmy2Date(Day: Integer; Month: Integer; Year: Integer; var NewDate: Date)
begin
    NewDate := DMY2Date(Day, Month, Year);
end;

This leads us to answer a very important question. What should we do if the function fails?

For this, I develop helper functions like these.

procedure IsDmyValidDate(Day: Integer; Month: Integer; Year: Integer): Boolean
var
    NewDate: Date;
begin
    exit(TryDmy2Date(Day, Month, Year, NewDate))
end;

procedure Dmy2DateWithDefault(Day: Integer; Month: Integer; Year: Integer; DefaultDate: Date) NewDate: Date
begin
    if not TryDmy2Date(Day, Month, Year, NewDate) then
        NewDate := DefaultDate;
end;

The IsDmyValidDate function allows the developer to check if the information is valid or not.

The Dmy2DateWithDefault function forces the developer to decide what date should be returned in case of failure. Maybe the default value is the 0D, or maybe Today() or something else.

Explained by Erik Hougaard

Yesterday (January 29, 2024) Erik posted this video on YouTube: Is your AL code robust or weak, what kind of developer are you with Business Central?

Erik walks you through the Microsoft Learn: Failure modeling and robust coding practices page.

Checking your preconditions is a good example of “Don’t trust consumers of your code” and my TryFunction example speaks for itself.

Clean AL Code Initiative

Elm Language and Maybe

This approach is inspired by the Elm Language. Elm is a delightful language for reliable web applications.

Elm can add Maybe to a domain type like Float.

The Maybe type is defined as

type Maybe a
    = Just a
    | Nothing

Example:

Convert a String to a Float.

> String.toFloat
<function> : String -> Maybe Float

> String.toFloat "3.1415"
Just 3.1415 : Maybe Float

> String.toFloat "abc"
Nothing : Maybe Float

The Dmy2DateWithDefault function above is inspired by the Maybe with Default.

We do not have a Maybe type in AL, but we have TryFunctions to keep us safe.