This is part 6 of the series. Everything so far computed values that were simply there. This part opens a file — and a file is the first thing in the series that is allowed to not be there. What happens next is the part of a language most programs meet on their worst day, so Mica makes it part of the signature, part of the call, and part of what the compiler checks — not a convention you remember under pressure.

The example is examples/FirstFailure in the tutorial repository: it counts the lines of a file named on the command line, notes.txt by default.

Getting the file

make -C examples/FirstFailure run
notes.txt has 3 lines

Now the road this part exists for — ask for a file that is not there. The built program is beside the example; run it with an argument:

$ examples/build/FirstFailure/FirstFailure no-such-file.txt
there is no file called no-such-file.txt here

No stack dump, no numeric mystery — a sentence, because the program was made to say one. Here is how.

The example, walked

The signature admits it

function CountLines(path : string) : int64 fails OsError;

fails OsError is the honest half of the return type: this function answers a count or a reason it could not. Counting lines needs the operating system, and the operating system can say no — the signature says so where every caller reads it. A function with no fails cannot fail; you have been relying on that in every part so far without knowing it.

Propagation is a word, not a pyramid

f := Open(path, ModeRead) on fail leave;
defer Close(address f);

got := ReadLine(f, address buf) on fail leave;

Inside CountLines, every fallible call carries on fail leave: if the callee fails, forward that failure on my own channel, now. The honest signature costs one clause per call. And defer Close — registered beside the Open it cleans up after — runs on the failure road too, because a forwarded failure is an ordinary exit. Nothing on any road leaks the file.

The call you cannot leave unfinished

lines := CountLines(path) on fail caught do
begin
    case caught of
        Enoent :
            WriteLn("there is no file called %ls here", path)
        else
            WriteLn("%ls could not be read, code %lld", path, Ord(caught))
    end;
    leave;
end;

The caller consumes the failure with on fail caught docaught is the reason as an ordinary value, and a case reads it like any other. The missing file has a name, Enoent, and the else keeps every other reason honest. leave ends the program’s block: there is no line count to print on this road, so the road ends.

What you cannot do is write lines := CountLines(path); and move on — the second experiment shows what the compiler says about that.

Try it

1. The other reasons exist too. Ask for a file you may not read:

$ examples/build/FirstFailure/FirstFailure /etc/shadow
/etc/shadow could not be read, code 10

The else arm earned its keep: not a missing file, so not Enoent — a permission wall, printed by its code here. The error domains tutorial is where the whole family gets its names.

2. Try to ignore the failure. Delete the entire on fail caught do begin … end clause, leaving lines := CountLines(path);, and rebuild:

analyzer error 5304: a call to the fallible function 'CountLines' must consume its failure channel: propagate with 'on fail leave', default with 'on fail use <expression>', handle with 'on fail e do <statement>', or discard a procedure's failure with 'on fail continue' — an unconsumed failure cannot exist

That message is the whole design in one sentence: four ways to consume a failure, and no way to not choose. The forgotten error path — the one that works in every demo and detonates in production — does not compile here.

3. Break the cleanup on purpose — and fail. Delete defer Close(address f); and run the missing-file road again. The behavior does not change — the file never opened, so there was nothing to close — and the present-file road still works, with the descriptor now returned only when the program exits. The point of defer is not this small program; it is that the cleanup was written once, beside the resource, and rode every road out. Put it back.

What the compiler proved

A function that can fail says so in its type. A call to it cannot be written without deciding the failure’s fate — propagate, default, handle, or deliberately discard. The reasons are values with names, so handling them is case logic, not string matching. And cleanup registered with defer rides the failure exit for free, so the arrangement has no bad day.

Next

The crossroads — part 7: one program that uses everything the numbered path taught, and the guided map into the themed tutorials where each piece becomes properly yours. Every example behind the series lives in the tutorial repository.