Godot Optional

by Tienne_k

26

godot-optional

Better error handling for Godot!

Introduces to Godot Options and Results inspired by Rust

Features

  • Optionals to explicitly annotate that a variable can be null
  • TimedVars that keep track of how long they've existed for, and can delete themselves after some time
  • Results (experimental) to explicitly annotate that an operation can fail

Option

A generic Option<T>

Options are types that explicitly annotate that a value can be null, and forces the user to handle the exception

func player_attack_regular():
    # The player may or may not be holding anything
    # Just by looking at this line, it's unclear whether `weapon` can be null
    var weapon: Dictionary = { "id": "sword", "durability": 2 }
    print("Player attacks!")
    # Use durability if holding a weapon
    # If we omit these "!= null" checks, it will lead to undefined behavior!
    if weapon != null:
        weapon.durability -= 1
        if weapon.durability <= 0:
            print(weapon, " broke")
            weapon = null

func player_attack_with_option():
    # Here, it's clear that `weapon` can be null
    var weapon: Option = Option.Some({ "id": "sword", "durability": 2 })
    print("Player attacks!")
    # Use durability if holding a weapon, and remove it if it breaks
    # take_if returns an Option containing the taken value
    weapon.take_if(func(w: Dictionary):
            w.durability -= 1
            return w.durability <= 0
        )\
        # Print the proken weapon
        .if_some(func(old_w: Dictionary):  print(old_weapon, " broke"))

Basic usage:

# By returning an Option, it's clear that this function can return null, which must be handled
func get_player_health(id: String) -> Option:
    return Option.None() # Represents a null
    return Option.Some(100) # Sucess!

var opt: Option = get_player_health("player_3")
if opt.is_none():
    print("Player doesn't exist!")
    return

# Getting the contained value
var data = opt.unwrap_or( 42 ) # Get or provided default
var data = opt.unwrap_or_else( some_complex_function ) # Get or default value from function
var data = opt.expect("opt can't be None because we checked above") # Assert this isn't a `null`, and get the inner value
var data = opt.unwrap() # Crashes if None
var data = opt.unwrap_unchecked() # Least safe. It's okay to use it here because we've already checked above
print(opt) # Prints: "Some(100)"

# Doing checks on Options
if opt.matches(100):
    print("Player is at 100 health!")
elif opt.is_some_and(func(health: int):    return health <= 10):
    print("Player has critically low health!")

Option also comes with a safe way to index arrays and dictionaries

var my_arr = [2, 4, 6]
print( Option.arr_get(1) )  # Prints "Some(4)"
print( Option.arr_get(4) )  # Prints "None" because index 4 is out of bounds

Result

A generic Result<T, E>

Results are types that explicitly annotate that an operation (most often a function call) can fail, and forces the user to handle the exception

In case of a success, the Ok variant is returned containing the value returned by said operation In case of a failure, the Err variant is returned containing information about the error.

func file_open():
    # Should fail because the file doesn't exist
    var res1: Result = Result.open_file("res://nonexistent_file.txt", FileAccess.READ)\
        .gderror_to_string()\
        .map(func(fileaccess: FileAccess):  return fileaccess.get_as_text())
    print(res1) # Should print: "Err(Report: File not found { "path": "res://nonexistent_file.txt" })"

    var res2: Result = Result.parse_json_file("res://data.json")
    print(res2) # Should print: "Ok( [contents of data.json] )"

Basic usage:


# By returning a Result, it's clear that this function can fail
func my_function() -> Result:
    return Result.Ok("foo") # Success!
    return Result.from_gderr(ERR_PRINTER_ON_FIRE)
    return Result.Err(&"my error")
    # With an error report
    return Result.Err(Report.new()
        .info("expected", "some_value")
        .info("found", "some_other_value")
        .cause(ERR_BUSY)
        )

var res: Result = my_function()
# Ways to handle results:
if res.is_err():
    res\
        # @GlobalScope.Error to String
        .gderror_to_string()\
        # Report this error. See the "Reports" section below
        .report()
    return

# Getting the contained value
var data = res.unwrap_or( 42 ) # Get if Ok(value) or provided default
var data = res.unwrap_or_else( some_complex_function ) # Get or default value from function
var data = res.expect("my_function() somehow failed!") # Assert this isn't an `Err`, and get the inner value
var data = res.unwrap() # Crashes if Err
var data = res.unwrap_unchecked() # Least safe. It's okay to use it here because we've already checked above
print(res) # "Ok( whatever data is contained )"

# Doing checks on Results
if res.matches("foo"):
    print("my_function() was a success and returned 'foo'!")

Result also comes with a safe way to open files and parse JSON

 var res: Result = Result.open_file("res://file.txt", FileAccess.READ) # Result<FileAccess, Report>
 var json_res: Result = Result.parse_json_file("res://data.json") # Result<data, Report>

TimedVar

TimedVars are variables that keep track of when they were created and can expire after a certain amount of time if configured to.

When expired, the contained value will be deleted (set to null / None)

Example: Creating a combo system using TimedVars

var combo: TimedVar = TimedVar.empty() # Combo not yet started
print("  combo = ", combo) # "TimedVar(<null>: alive for 0.00s)"

# Player input ...

# Start the combo with a slash
print("SLASH!")
combo = TimedVar.with_lifespan("slash", 1000)
# Same as writing one of the following:
combo = TimedVar.new(&"slash") .set_lifespan(1000)
combo.set_value("slash") .set_lifespan(1000) # 1s window for following combos

# Now, combo = TimedVar(slash: expires in 1.00s)
# Player input ...

# Follow it up with a big slash
if combo.get_value() .matches("slash"):
	print("BIG SLASH!")
	combo.set_value(&"slash_big") # Also resets lifespan back to that 1s window we defined earlier
else:
    # Too late! `combo` already expired, so no more follow-ups!
	print("No big slash")

# Now, combo = TimedVar(slash_big: expires in 1.00s)
# Player input ...

# End it with a 'biggest slash', but with a tighter timing window of 0.5s
# Using take() (or in this case, take_timed()) takes care of finishing the
#  combo with no loose ends
if combo.take_timed(500) .matches(&"slash_big"):
	print("BIGGEST SLASH ULTIMATE!!!")
else:
    # Too late! `combo` already expired!
	print("No biggest slash :(")

# Now, combo = TimedVar::Expired

Version

3.0

Engine

4.2

Category

Scripts

Download

Version3.0
Download

Support

If you need help or have questions about this plugin, please contact the author.

Contact Author