Skip to content

Error Safety

Code can fail unexpectedly for many reasons. While Fusion tries to prevent many errors by design, Fusion can't stop you from trying to access data that doesn't exist, or taking actions that don't make sense to the computer.

So, you need to be able to deal with errors that happen while your program is running.


Fatality

An error can be either fatal or non-fatal:

  • fatal errors aren't handled by anything, so they crash your program
  • non-fatal errors are handled by Fusion and let your program continue

You're likely familiar with fatal errors. You can create them with error():

print("before")
print("before")
print("before")

error("Kaboom!")

print("after")
print("after")
print("after")
before
before
before
Main:7: Kaboom!
Stack Begin
Main:7
Fusion.State.Computed:74 function update
Fusion.State.Computed:166 function Computed
Main:6
Stack End

You can make it non-fatal by protecting the call, with pcall():

print("before")
print("before")
print("before")

pcall(function()
    error("Kaboom!")
end)

print("after")
print("after")
print("after")
before
before
before
after
after
after

Example

To demonstrate the difference, consider how Fusion handles errors in state objects.

State objects always run your code in a safe environment, to ensure that an error doesn't leave your state objects in a broken configuration.

This means you can broadly do whatever you like inside of them, and they won't cause a fatal error that stops your program from running.

print("before")
print("before")
print("before")

scope:Computed(function()
    error("Kaboom!")
end)

print("after")
print("after")
print("after")
before
before
before
[Fusion] Error in callback: Kaboom!
  (ID: callbackError)
  ---- Stack trace ----
  Main:7
  Fusion.State.Computed:74 function update
  Fusion.State.Computed:166 function Computed
  Main:6

Stack Begin
Stack End
after
after
after

These are non-fatal errors. You don't have to handle them, because Fusion will take all the necessary steps to ensure your program keeps running. In this case, the Computed object tries to roll back to the last value it had, if any.

local number = scope:Value(1)
local double = scope:Computed(function(use)
    local number = use(number)
    assert(number ~= 3, "I don't like the number 3")
    return number * 2
end)

print("number:", peek(number), "double:", peek(double))
    --> number: 1 double: 2

number:set(2)
print("number:", peek(number), "double:", peek(double))
    --> number: 2 double: 4

number:set(3)
print("number:", peek(number), "double:", peek(double))
    --> number: 3 double: 4

number:set(4)
print("number:", peek(number), "double:", peek(double))
    --> number: 4 double: 8

Be Careful

Just because your program continues running, doesn't mean that it will behave the way you expect it to. In the above example, the roll back gave us a nonsense answer:

--> number: 3 double: 4

This is why it's still important to practice good error safety. If you expect an error to occur, you should always handle the error explicitly, and define what should be done about it.

local number = scope:Value(1)
local double = scope:Computed(function(use)
    local number = use(number)
    local ok, result = pcall(function()
        assert(number ~= 3, "I don't like the number 3")
        return number * 2
    end)
    if ok then
        return result
    else
        return "failed: " .. err
    end
end)

Now when the computation fails, it fails more helpfully:

--> number: 3 double: failed: I don't like the number 3

As a general rule, your program should never error in a way that prints red text to the output.


Safe Expressions

Functions like pcall and xpcall can be useful for catching errors. However, they can often make a lot of code clunkier, like the code above.

To help with this, Fusion introduces safe expressions. They let you try and run a calculation, and fall back to another calculation if it fails.

Safe {
    try = function()
        return -- a value that might error during calculation
    end,
    fallback = function(theError)
        return -- a fallback value if an error does occur
    end
}
To see how Safe improves the readability and conciseness of your code, consider this next snippet. You can write it using Safe, xpcall and pcall - here's how each one looks:

local double = scope:Computed(function(use)
    local ok, result = pcall(function()
        local number = use(number)
        assert(number ~= 3, "I don't like the number 3")
        return number * 2
    end)
    if ok then
        return result
    else
        return "failed: " .. err
    end
end)
local double = scope:Computed(function(use)
    local _, result = xpcall(
        function()
            local number = use(number)
            assert(number ~= 3, "I don't like the number 3")
            return number * 2
        end,
        function(err)
            return "failed: " .. err
        end
    )
    return result
end)
local double = scope:Computed(function(use)
    return Safe {
        try = function()
            local number = use(number)
            assert(number ~= 3, "I don't like the number 3")
            return number * 2
        end,
        fallback = function(err)
            return "failed: " .. err
        end
    }
end)

pcall is the simplest way to safely handle errors. It's not entirely convenient because you have to check the ok boolean before you know whether the calculation was successful, which makes it difficult to use as part of a larger expression.

xpcall is an improvement over pcall, because it lets you define the fallback value as a second function, and uses its return value as the result of the calculation whenever an error occurs. However, it still returns the ok boolean, which has to be explicitly discarded.

Safe is an improvement over xpcall, because it does away with the ok boolean altogether, and only returns the result. It also clearly labels the try and fallback functions so you can easily tell which one handles which case.

As a result of its design, Safe can be used widely throughout Fusion to catch fatal errors. For example, you can use it to conditionally render error components directly as part of a larger UI:

[Children] = Safe {
    try = function()
        return scope:FormattedForumPost {
            -- ... properties ...
        }
    end,
    fallback = function(err)
        return scope:ErrorPage {
            title = "An error occurred while showing this forum post",
            errorMessage = tostring(err)
        }
    end
}

Non-Fatal Errors

As before, note that non-fatal errors aren't caught by Safe, because they do not cause the computation in try() to crash.

-- The `Safe` is outside the `Computed`.
-- It will not catch the error, because the `Computed` handles the error.
local result = Safe {
    try = function()
        scope:Computed(function()
            error("Kaboom!")
        end)
        return "success"
    end,
    fallback = function(err)
        return "fail"
    end
}

print(result) --> success

You must move the Safe closer to the source of the error, as discussed before.

-- The `Safe` and the the `Computed` have swapped places.
-- The error is now caught by the `Safe` instead of the `Computed`.
local result = scope:Computed(function()
    return Safe {
        try = function()
            error("Kaboom!")
            return "success"
        end,
        fallback = function(err)
            return "fail"
        end
    }
end)

print(peek(result)) --> fail
Back to top