ErrorSentinel | Python-like Error Handling (try-except-etc)

ErrorSentinel - Python-Style Exception Handling for Roblox

ErrorSentinel allows the use of ‘exception handling’ to LuaU, offering Python-inspired try-catch-finally blocks, hierarchical exception types, and error management. This aims to stop the use of pcalls, in a more intriguing manner.
I highly suggest you take the time to read this!


Overview

Purpose: Provides the art of exception handling with very familiar Python-style syntax for LuaU!
Target Audience: Developers who may seek robust error management beyond basic pcall patterns
Requirements: Intermediate skill-level in LuaU, and a small amount of familiarity in Python (not much!)


Key Features

  • Exception System - Organized exception types with inheritance chains (i.e., ValueError, TypeError, RuntimeError, etc.)
  • Try-Catch-Finally Blocks - If you are familiar with the Promise pattern, this provides familiar control with ‘except’, ‘else’ and ‘finally’ handlers!
  • Type Safety - Fully LuaU type annotated!
  • Safe Function Wrapping - Very useful function that transforms any function into a non-throwing variant with default returns!
  • Context Managers - ‘with’ function that provides resource management with automatic cleanup

Important Notice

Sentinel doesn’t actually know when to exactly cause an exception. It doesn’t know when it occurs; whether it is from a DataStoreService; HTTPService etc. Exceptions that are NOT RuntimeErrors are purely for controlled code flow.

Sentinel does know a RuntimeError; anytime the LuaU scheduler throws an error; Sentinel catches that error if there is an Exception defined for it.


Installation

  • See down below, Downloads

Quick Start

lua

-- How to use quickly
local Error = require(game:GetService("ReplicatedStorage").ErrorSentinel)
local Exceptions = Error.Exceptions

-- Basic try-catch pattern
-- This demonstrates the creation of an Instance that catches any error (Type or Runtime)
local function createInstance(className: string)
	return Error.try(true,
		function()
			if type(className) ~= "string" then
				-- Raise an error
				Error.raise(Exceptions.TypeError.new(`Expected a string, got '{className}'`))
			end
			-- If className isn't a valid Instance, this will also error; and is considered a Runtime error
			-- since we don't have direct control of it
			return Instance.new(className)
		end,
        -- Let's check for a RuntimeError (Could be triggered if className isnt a valid Instance)
		Error.except(Exceptions.RuntimeError, function(exc)
			warn(`RuntimeError triggered: '{exc.message}'`)
		end),
		-- Let's also check for the TypeError (Which is manually raised)
		Error.except(Exceptions.TypeError, function(exc)
			warn(`TypeError triggered: '{exc.message}'`)
		end)
	)
end

local part = createInstance("Part") -- This creates a part.
-- If you were to not pass a string, or it is a string but not a valid Instance, it will get caught

More Usage Examples

Basic Usage

Simple try-catch with specific exception types

lua

local function divide(a, b)
    return ErrorSentinel.try(
        function()
            if type(a) ~= "number" or type(b) ~= "number" then
                ErrorSentinel.raise(Exceptions.TypeError.new("Both arguments must be numbers"))
            end
            if b == 0 then
                ErrorSentinel.raise(Exceptions.ZeroDivisionError.new("Cannot divide by zero"))
            end
            return a / b
        end,
        ErrorSentinel.except(Exceptions.ZeroDivisionError, function(e)
            warn("Division error:", e.message)
            return 0
        end),
        ErrorSentinel.except(Exceptions.TypeError, function(e)
            warn("Type error:", e.message)
            return nil
        end)
    )
end

local result = divide(10, 2)  -- Returns 5
local result2 = divide(10, 0) -- Returns 0, warns about division

Try-catch with else and finally blocks

lua

local success = ErrorSentinel.try(
    function()
        -- Risky operation
        return performDatabaseOperation()
    end,
    ErrorSentinel.except(Exceptions.RuntimeError, function(e)
        print("Database operation failed:", e.message)
    end),
    ErrorSentinel.else_(function()
        print("Database operation completed successfully")
    end),
    ErrorSentinel.finally(function()
        print("Cleaning up database connection")
        cleanup()
    end)
)

Advanced Usage

Safe function wrapping for external APIs

lua

local HttpService = game:GetService("HttpService")

local safeHttpGet = ErrorSentinel.safe(
    function(url)
        local response = HttpService:GetAsync(url)
        return HttpService:JSONDecode(response)
    end,
    -- This is where defaults can be specified if errors
    {error = "Request failed"}, -- Default return value
    -- This is just an exception function that warns
    function(exception)
        warn("HTTP request error:", exception.message)
    end
)

-- Never throws, always returns a table
local data = safeHttpGet("https://api.example.com/data")

API Reference

Core Functions

ErrorSentinel.try(returnBoth?, tryBlock, …handlers)

  • Parameters:
    • returnBoth: boolean? - If true, returns success, result tuple; if false/nil, returns single result
    • tryBlock: () -> any - Function to execute
    • ...handlers: ExceptionHandler - Exception handlers created with except/else_/finally
  • Returns: any or (boolean, any) depending on returnBoth parameter
  • Description: Executes tryBlock with structured exception handling

ErrorSentinel.raise(exception)

  • Parameters: exception: Exception | string - Exception to raise (strings are wrapped in base Exception)
  • Returns: never - Always throws
  • Description: Raises an exception, similar to Python’s raise statement

ErrorSentinel.except(exceptionTypes, handler)

  • Parameters:
    • exceptionTypes: ExceptionConstructor | {ExceptionConstructor} - Exception type(s) to catch
    • handler: (exception) -> any - Function to handle the exception
  • Returns: ExceptionHandler - Handler for use with try()
  • Description: Creates exception handler for specific exception types

ErrorSentinel.safe(func, defaultReturn, exceptionHandler?)

  • Parameters:
    • func: (...any) -> R - Function to wrap
    • defaultReturn: R - Value to return if exception occurs
    • exceptionHandler: ((exception) -> ())? - Optional exception processor
  • Returns: (...any) -> R - Wrapped function that never throws
  • Description: Creates safe wrapper that catches all exceptions and returns default value

ErrorSentinel.isinstance(obj, classTypes)

  • Parameters:
    • obj: any - Object to check
    • classTypes: ExceptionConstructor | {ExceptionConstructor} - Type(s) to check against
  • Returns: boolean - True if obj is instance of any classType
  • Description: Checks if object is instance of given exception type(s), including inheritance

Exception Types

lua

-- Base hierarchy
ErrorSentinel.Exceptions.BaseException
ErrorSentinel.Exceptions.Exception

-- Standard exceptions
ErrorSentinel.Exceptions.ValueError
ErrorSentinel.Exceptions.TypeError  
ErrorSentinel.Exceptions.IndexError
ErrorSentinel.Exceptions.KeyError
ErrorSentinel.Exceptions.AttributeError
ErrorSentinel.Exceptions.RuntimeError
ErrorSentinel.Exceptions.NotImplementedError
ErrorSentinel.Exceptions.ZeroDivisionError
ErrorSentinel.Exceptions.FileNotFoundError
ErrorSentinel.Exceptions.PermissionError

-- Roblox-specific exceptions
ErrorSentinel.Exceptions.RobloxError
ErrorSentinel.Exceptions.RemoteEventError
ErrorSentinel.Exceptions.DataStoreError
ErrorSentinel.Exceptions.NetworkError

Performance Notes

An important thing to note is, while this does provide extensive error handling, it is also significantly more overhead than simple pcalls or error calls.
The difference is probably negligible, just important to know.


Download

Get on Roblox Marketplace: ErrorSentinel | Python-like Error Handling


Feedback

Any feedback is genuinely appreciated, I highly suggest you give this a chance and give your opinions.

5 Likes

Another important thing to note about Exceptions is, Sentinel doesn’t actually know for example, a “DataStoreError” error and when it occurs, It is purely for organizational contexts and controlled code flow that makes sense.
Any exception that isn’t a Runtime error, must be manually raised, or else that exception won’t get handled.

Let’s say you are creating a DataStore script, with a Get function:
lua

function DataStoreManager:Get(key: string, defaultValue: any?)
	local startTime = DateTime.now().UnixTimestamp
	local success = false
	
	return Error.try(false,
		function()
			if type(key) ~= "string" or key == EMPTY_STRING then
				Error.raise(Exceptions.ValueError.new(`'key' expected a non-empty string, got '{key}'`))
			end
			
			local result = self._dataStore:GetAsync(key)
			-- If we get, here 'GetAsync' succeeded
			success = true
			return result ~= nil and result or defaultValue
		end,
		Error.except(Exceptions.ValueError, function(e)
			warn(`'GetAsync' failed with 'ValueError': {e.message}`)
			return defaultValue
		end),
		Error.except(Exceptions.DataStoreError, function(e)
			-- THIS IS USELESS: Never gets triggered unless it is manually raised in the 'try' block
		end)
        -- What you should do:
		Error.except(Exceptions.RuntimeError, function(e)
			-- This essentially catches any error that Roblox normally throws,
			-- since it is quite impossible to see exactly what caused this error.
			-- is it because of "DataStore"? Is it because of "HTTPService"?
			warn(`'GetAsync' failed with a 'RuntimeError': {e.message}`)
		end)
	)
end

TL:DR

  • RuntimeError acts as the catch net for whatever Roblox decides to throw at us

  • Specific exceptions (DataStoreError, RemoteEventError, etc.) are intentionally raised by developers who know the context

  • Manual raising becomes a feature, not a bug

Isn’t this exactly what assert and pcall does in a more beginner-friendly way?

1 Like

Small Change- ErrorSentinel

Changed:

  • Updated .try() to allow more than one return result from the pcall.
  • More comments and error optimization

Allows you to write your code like this:
lua

-- Hypothetical scenario. Sentinel/Error isn't really useful here. and probably shouldnt be used.
-- pretend this is an extremely critical scenario
local function processPayment(pin: number, amt: number)
	return Error.try(false,
		function()
			-- Verify that pin is a number
			if type(pin) ~= "number" then
				Error.raise(Exceptions.TypeError.new(`'pin' expected a number, got {pin}`))
			end
			-- Verify amt is also a number
			if type(amt) ~= "number" then
				Error.raise(Exceptions.TypeError.new(`'amt' expected a number, got {amt}`))
			end
			-- Verify userBalances is a table
			if type(userBalances) ~= "table" then
				Error.raise(Exceptions.TypeError.new(`'userBalances' expected a table, got {userBalances}`))
			end
			-- Get the balance for this user (What if they don't have a key?)
			local balance = userBalances[pin] -- Could be nil
			-- Check that balance is a number
			if type(balance) ~= "number" then
				Error.raise(Exceptions.TypeError.new(`'balance' Expected a number, got {balance}`))
			end
			-- We know balance is a numbner
			-- Check balance is less than out amt
			if balance < amt then
				-- raise a ValueError
				Error.raise(Exceptions.ValueError.new(`User with pin: '{pin}' does not have enough money!: Tried to buy at amount: '{amt}', but only has: '{balance}'`))
			end
			local amtToDeduct = balance - amt
			balance = amtToDeduct -- Makes sure to update balance
			-- Execute payment transaction!
			-- Return a boolean of true, and the amount that is left of balance
			return true, amtToDeduct
		end,
		Error.except(Exceptions.TypeError, function(e)
			warn(`Failed with 'TypeError': {e.message}`)
		end),
		-- Check for a ValueError
		Error.except(Exceptions.ValueError, function(e)
			warn(`Failed with a 'ValueError': {e.message}`)
		end)
		-- Runtime exception should not be needed; as we handled any case where an error would occur!
	)
end

local function registerPin(newPin: number)
	if type(newPin) ~= "number" then
		warn(`Expected a number, got {newPin}`)
	end
	if #tostring(newPin) > 4 then
		warn(`'pin' is more than 4 digits: {newPin}`)
	end
	-- Add a new entry or keep it
	userBalances[newPin] = userBalances[newPin] or DEFAULT_BALANCE
	-- Returns
	return userBalances[newPin]
end

registerPin(2325)

-- Catches all return values
local processed, newBal = processPayment(2325, 50)
print(processed, success,  newBal)

Sentinel isn’t just a beginner friendly wrapper.
It is also more for error enthusiasts, and people that like python’s error system.

Assert and pcall are useful tools don’t get me wrong, and there may be times where an assertion may be used instead, but assert and pcall give you generic errors with not much context.

Sentinel:
Allows the use of specific exception types, which makes the code look, and flow better,

One of the most useful features is you can catch specific errors and do stuff, but let others bubble up.

Additionally includes guaranteed cleanups through .finally blocks, ContextManager settings with a enter, and exit methods with the .with function, and generally just makes your code look a lot cleaner.

Some people probably wont use this or find it useful for most simple projects or games.
But if you are working in a team setting (other team members can clearly see what you are trying to do), and require complex and robust error handling, it can be useful.

I would love if there was method chaining syntax for this instead of passing a table

1 Like

I was thinking of adding method chaining. Maybe in the future or in the next update for it. That way it can kinda function like a promise implementation.
Thanks for the feedback!

1 Like