Result | Advanced Error handling | Inspired by Rust's Result type

Result

Hello developers,

Today I will give you an extremely simple library for error handling

Easy | Result | Simple

Get in on Creator Store | GitHub

Api Usage:

--!strict
local Result = require(path.to.Result)

type Result<T, E> = Result.Result<T, E> -- for more "clean" code

local function some_func(num: number): Result<number, string>
    if num == 4 then
       return Result.Err("Number is 4!!!")
    else
       return Result.Ok(num)
    end
end

-- functions unwrap, unwrap_err, unwrap_or
local err_result = some_func(4)
--print(Result.unwrap(err_result)) -- <- error, try unwrap on Err
print(Result.unwrap_err(err_result)) -- log: Number if 4!!!
print(Result.is_ok(err_result)) -- log: false

local ok_result = some_func(1337)
print(Result.unwrap(ok_result)) -- log: 1337
print(Result.is_err(ok_result)) -- log: false
print(Result.is_ok(ok_result)) -- log: true

-- example with unwrap_or
local error_result = some_func(4)
print(Result.unwrap_or(error_result, 1)) -- log: 1

This library is very simple, but provides good error handling for any project

how u rate Result library
  • this library sounds good, but idk how rate this
  • maybe next time
  • i will dont use this(but why :thinking:) reply and say why
  • i will use this right now!
  • ill check this library and rate later
0 voters

It’s to make something as simple as displaying a result, just “look nicer on the output” by adding tons of more stuff in the background.

For benchmarking? Maybe this is good. For anything else? Nope!

I suspect in most cases this should be more preferable:

1 Like

Why do you prefer this? This is literally fragile code (in real projects). Thats why I created Result. Its designed for real projects where you need to handle any error without exceptions. One such exception in a real project can break everything.

Result - it’s about preventing your entire game from crashing when players do unexpected things.

Without Result:

local data = some_operation() -- Hope it doesn't error, or everything crashes
if data == nil
    -- this code is still unsafe
else
   
end

With Result:


local result = some_opertaion()
if  Result.is_err(result) then 
       -- Handle it, warn("Error occurred:", result.error) 
else 
      -- some code
end

The ‘tons of stuff in the background’ you mentioned is literally just:

{ tag = "Ok", value = data } -- or { tag = "Err", error = reason }

That’s it. No magic, no overhead - just structured error handling lol.

Let me be blunt: if you think structured error handling is just ‘making output look nicer’, you haven’t maintained a large production codebase.

Every time you write local data = operation() without handling the error case, you’re creating a future bug. Every assert in business logic is a potential server crash waiting to happen.

So I absolutely disagree with you, and Result is not just a “look nicer on the output”, this is a really useful library in 90% of cases.

i don’t know if this would benefit someone-
because i can’t really think of a good use case :sob:

maybe try giving more examples where it would be good… and then ill change my mind

(i could also just be dumb rn)

I’m unlikely to give the code where this will be used now because I’m too lazy, but here’s where Result can really be used in 100% of cases:

  • using roblox API (DataStores, HTTP-requests)
  • Resource operations (like MarketPlaceService, inventory system)
  • ui logic for more detailed errors

That’s all I can come up with for now lol

Okay, I came up with a code example for you:

-- Traditional way - fragile
local success, response = pcall(function() return HttpService:GetAsync(url) end)
if not success then
    warn("HTTP failed".. tostring(response)) -- USELESS for debugging
end

-- With Result - structured errors
type HttpError = {
    code: number,
    message: string,
    retryable: boolean,
    url: string
}

function HttpGet(url): Result<any, HttpError>
    local success, response = pcall(function() return HttpService:GetAsync(url) end)
    
    if not success then
        return Result.Err({
            code = 0,
            message = "Network failure",
            retryable = true,
            url = url
        })
    end
    
    -- Parse JSON (can also fail!)
    local success, data = pcall(function() return HttpService:JSONDecode(response) emd))
    if not success then
        return Result.Err({
            code = 500,
            message = "Invalid JSON response",
            retryable = false,
            url = url
        })
    end
    
    return Result.Ok(data)
end

local result = HttpGet("https://api.site.lol")
if Result.is_err(result) and result.error.retryable then
    task.delay(5, function() some_retry_func() end)
end

This is a fail-fast system.

I am sorry, but if such things can happen in your project than it’s fragile and is already broken.
In real world usually there’s no way for applications to recover from unexpected errors.

Your approach eventually ends up with a black screen but not crashed process. I barely believe it’s your intention.

You’re absolutely right about fail-fast - its a valid philosophy for certain systems. but let me explain why fail-fast with assert is catastrophic for live game servers:

You’re comparing SYSTEM errors (OS, memory, drivers) and BUSINESS errors (validation, network).

For SYSTEM errors: You’re 100% correct - fail-fast, restart, recover.
For BUSINESS errors: Fail-fast means punishing players for normal behavior .

No Way to Recover’ - This is WRONG for Games:

In games, 90% of ‘errors’ are RECOVERABLE:

  1. Network timeout - retry
  2. Invalid input - show validation message
  3. Rate limited - wait and retry
  4. Http error - retry

this is also stupid like when you buy something on Amazon and instead of “Try another payment method” you getting “AMAZON HAS CRASHED” And getting app reboot lol

1 Like

If that black screen can give the user a better explanation of what actually went wrong then that’s arguably far better than a crashed process. Gracefully handling unrecoverable errors and being able to explain to the user why it was unrecoverable is a much better UX and could potentially help them resolve the issue on their own if it’s not a code problem.

There’s already graceful developers console on F9.
If that crashes ask users to provide console output.
If that crashes only for users but no for developers, then, as you probably already know, it’s not good news for the product.

Their use of assert seems perfectly justified in that case. You would want to warn the programmer that passing in 4 is not allowed there, and it seems overly silly to expect the user of the function to unwrap the result every time they called it just to check if they passed in 4 or not.

In general, my philosophy for failure handling is:

  1. Return an optional value (T?) when the failure case is common and you do not need to differentiate the error type (couldn’t find the player’s humanoid).

  2. Throw an error if the inputs are invalid or would cause something unexpected to happen (index out of range). More specifically, panic when the programmer made a mistake and not the application.

  3. Wrap the return in a discriminate union if the result of the operation hit a failure case that is outside programmer control and needs to be differentiated (HTTP request failed with a 404).

Personally, I would discourage people from using tiny dependencies like these under the “You Really Don’t Need It” principle. Dependencies aren’t evil, but if all they do is provide a small abstract datatype that you could realistically inline yourself, then there’s no point in having them.

1 Like

I agree with your philosophy, sometimes it can actually be useful. As for 4 and assert, that was a silly example of api usage, and Result is used for this cases:

V 1.1.0

Changelog:

  • Added 3 new functions:

    • unwrap_or_function ← calls function if unwrap equals Err
    • and_then ← calls function if Result equals Ok
    • try ← wrap of pcall with Result support

Using new functions in example:

local Result = require(path.to.Result)

type Result<T, E> = Result.Result<T, E>

local function do_something(num: number): Result<number, string>
    if num == 0 then
        return Result.Err(`This is a Err`)
    else 
        return Result.Ok(num)  
    end
end

-- using and_then
local result = do_something(32)
Result.and_then(result, function(value: number)
    value += 10 
    print(value)
end)
local Result = require(path.to.Result)

type Result<T, E> = Result.Result<T, E>

local function do_something(num: number): Result<number, string>
    if num == 0 then
        return Result.Err(`This is a Err`)
    else 
        return Result.Ok(num)  
    end
end

-- using unwrap_or_function
local result = do_something(0)
Result.unwrap_or_function(result, function(err: string)
    warn(`Error occured, Error: {err}`)
end)
local Result = require(path.to.Result)

type Result<T, E> = Result.Result<T, E>
local HttpService = game:GetService("HttpService")

-- using try
local function fetchUrl(url: string): Result<string, string>
	return Result.try(function()
		local response = HttpService:GetAsync(url)

		-- something operations
	end)
end

local url = "MyCoolSite.com"
local result = fetchUrl(url)
Result.and_then(result, function()
    -- this code will be execute if result equals Ok
end)

Once we have figured out in which scenarios the module should be used, let’s make some unsolicited code review.
I. --------
tag: "Ok",
this is actually ok : boolean.
changing this simplifies 7 if conditions in the rest of the file.

II. --------
function Result.Ok<T>(value: T): { tag: "Ok", value: T }
and
function Result.Err<E>(err: E): { tag: "Err", error: E }
speaking about return type i guess that’s why types exist.
you can do

type result = {
	ok : boolean;
}

type result_ok<T> = result & {
	value: T,
}

type result_error = result & {
	error: string,
}

export type Result<T, E> = result_ok<T> | result_error

and use the types as the methods` return type, so later if you decide to add something, you change only one place

III. --------
Result<T, E>
i am not really sure that E is required here because it’s always string.
you can change error to any and rely on developers metatable.__tostring(table) of the error (if you ever want to print it) because developers might want to put whatever in it to simplify debugging.

Thank you!

Thanks for the suggestions, Ill rewrite the code as you requested and compare the current and your versions of the code

Howdy, just wanted to chime in on the convo.

Assertions are used to validate developers assumptions, statements we assume and expect to be true every time. Asserts are great at letting us know when an unrecoverable error happened and that we’ve made a severe programming mistake, like passing a incorrect type for a parameter. This is important when it comes to something your game relies on, for example, checking for missing instances like the Humanoid. However, you don’t always want to stop a thread when you get an error.

A lot of Roblox API requires you to use pcalls when making requests, like when you use DataStoreService to fetch saved player data. We can use pcalls to prevent requests, and our own functions from stopping the tread, and handle the case when it does error. By using pcalls, you utilize error handling making your code more robust. This prevents your game breaking from recoverable errors.

Rust’s Result is a semi-equivalent to Luau’s pcall; Result can either be a error type or a result type, and you can run relative code depending on the type (just like how you would with a pcall). But why should you use Result if it’s the same as pcall?

pcall’s main purpose is to prevent your code from breaking when failing to make requests. Additionally, pcall’s only ever returns a string for it’s error, hiding its number and stack trace, making it unideal for debugging. Result, on the other hand, helps keep your code clean, allows custom error data, and can be used outside of requests while still be considered good practice. For example:

local function divide(numerator: number, denominator: number): Result<number, string> {
    if denominator == 0 then
        return Result.Err("Cannot divide by zero")
    else
        return Result.Ok(numerator / denominator)
    end
}

Instead of stopping and erroring when ever we divide by 0, we can let the code that calls this function handle how to deal with dividing by 0. Maybe it errors and stops the thread, or maybe it uses 0, or something else entirely.

Error handling in games isn’t needed and can be a unnecessary burden, especially when making something small like a prototype. However, in large projects it can make debugging easier, and keep the game robust.