How to deal with return types on error?

Godot Version

4.4

Question

I’ve got a function and I’m trying to type both the parameters and the return type (for example “-> Rect2”).

But I’ve got various error checks within the function, and on error I’d like to:

print(“error msg”)
return void/null/nothing!

But it causes an error as the return type isn’t a Rect2. Is there a method to allowing Rect2 or void/null?

Hi,

You cannot change the returned type of a function.

Usually, in such a case, you would simply return a default value, like return Rect2(). Printing the error will allow you to know that something went wrong anyway.

Also, according to the Rect2 documentation:

In a boolean context, a Rect2 evaluates to false if both position and size are zero (equal to Vector2.ZERO). Otherwise, it always evaluates to true.

Which means that, by returning a default rect in error cases, you can then use it as a boolean to know if it is valid or not.

Let me know if that helps.

2 Likes

Just tried something out: alternatively, you can remove the returned type specification, and then return anything from the function, like this:

func get_rect():
    var some_error: bool = true
    if some_error:
        return null
	
    return Rect2(0, 0, 1, 1)

Which allows you to then do something like this:

var rect = get_rect()
if rect is Rect2:
	print(rect.size)
else:
	print('null')

The problem with a function that can return anything is that, well, it can return anything, which could cause some trouble when reading the code.
A function with a defined returned type ensures a clearer code, but then you’d have to adapt to that type when handling errors, as explained in my previous post. I don’t believe there’s an absolute right way of handling that problem, you should go for the way you think is the best fit for your code.

1 Like

If you have a function like this:

func make__rect_2(x: float, y: float, h: float, w: float) -> Rect2:
	return Rect2(Vector2(x, y), Vector2(h, w))

Then you have to pass floats in otherwise an error occurs. And you have to return a Rect2 otherwise an error occurs. The use of null or false is not allowed as these are not allowed versions of a Rect2.

But you could do this:

func make__rect_2(x: float, y: float, h: float, w: float) -> Variant:
	if is_error:
		return false
	return Rect2(Vector2(x, y), Vector2(h, w))

Or you can return a dictionary something like this:

func make__rect_2(x: float, y: float, h: float, w: float) -> Dictionary:
	var result: Dictionary = {}
	if is_error:
		result["is_succesful"] = false
		result["error_description"] = "Some value was not in an acceptable range"
	else:
		result["is_successful"] = true
		result["rect_2"] = Rect2(Vector2(x, y), Vector2(h, w))
	return result 

Or as @sixrobin said you can just not type the return value which will then default to variant.

Personally I like the fact you cannot just set things like Rect2 to false or null etc. I can recall in other languages constantly having to check in every function is it null, is it empty, is it set etc etc. Such a pain and bugs could proliferate far from the source of the bug until something finally failed.

PS The dictionary example could have been neater!

1 Like

I would expand based on this example and make a custom NullableRect2 class to be used instead of Dictionary.
This way it can be type-safe with proper autocompletion. Here’s a rough implementation of that idea, using an inner class for simplicity, but it could be a public class as well:

extends Node


class NullableRect2:
	var rect: Rect2
	func _init(position: Vector2, size: Vector2) -> void:
		rect = Rect2(position, size)


func get_rect_or_null(position: Vector2, size: Vector2) -> NullableRect2:
	if position == Vector2.ZERO and size == Vector2.ZERO:
		return null
	else:
		return NullableRect2.new(position, size)


func other_function() -> void:
	var my_nullable_rect: NullableRect2 = get_rect_or_null(Vector2(1, 1), Vector2(1, 1))
	if my_nullable_rect == null:
		printerr("NullableRect2 is null")
		return
	print(my_nullable_rect.rect)
1 Like

That’s interesting. However why not just use a variant return?

func get_rect_or_null(position: Vector2, size: Vector2) -> variant:
	if is_error_for_some_reason:
		return false
	else:
		return Rect2(position, size)

I also personally am uncomfortable using things like Vector2.ZERO to indicate an error. For instance a position of that vector2 is perfectly valid, and so is a size. At least with a set_cell and cell_atlas, it makes sense that a cell_atlas cannot be negative, so (-1, -1) unsets it, but personally I still don’t like it (and of course we have the simpler erase_cell.)

But it is also a bit of a pain to interpret a return dictionary for success or error messages etc. Seems too clunky to me, with the use of magic strings.

It is a shame you cant do something like:

func do_something() -> null or Rect2 

Like just literally a minute ago I ran into this problem, my queue class:

func get_next_action_in_queue() -> Vector2i:
	return Queue.pop_front()

I really wanted to do this but you can’t.

func get_next_action_in_queue() -> Vector2i:
	if Queue.is_empty():
                return false
        return Queue.pop_front()

And I did not want to return a dictionary nor a variant. So now I just have to make sure that before trying to get the next item in the queue I have to check it is not empty.

	if not ChunkGenerationQueue.is_queue_empty():
		_generate_chunk(ChunkGenerationQueue.get_next_action_in_queue())

Which is still neater than returning a dictionary, and more typesafe than returning a variant, but I wish I could enforce it a bit more just in case I forget in the future! It seems vunerable at the moment.

I could throw an error I suppose if you try to get a task from the queue if the queue is empty. Hmm, that is probably not a bad idea. What do you think?

Because it’s not type safe and you don’t know what is going to be returned from the function, until you read the full implementation of the function. I basically never use a Variant in any of my functions and variables if I can help it.

Of course, agreed, this was just my rough implementation to give an example. A proper implementation should be more complex.

What you’re describing you wish you could use is exactly a nullable type. These are not supported in GDScript currently, there is a proposal for that, but the issue is complex, so it takes time to develop properly. Nullable types are supported in C#, so in theory you could write your script in C# if you need, as you can mix and match GDScript and C# in one project. My proposed solution is kinda mimicking the behavior of a nullable type.

2 Likes

@wchc That was a really interesting read - thank you.

I think your suggestion was pretty good actually. Especially actually making it explicit in the function call somehting like:

get_next_action_in_queue_or_null()

And then using a more complicated set up like you first suggested with a nullable_vector2i class or something similar.

I do like the fact things like Vector2i or Rect2 can’t be null on the whole, and it does make life easier generally, but there are times…

So in C# (and I am not familiar with C#) it seems you can turn any type into a nullable type with the use of ?. If that is right, that seems like a good solution if it can be made to work in GDscript. But I can understand more now how your suggstion is a good solution for now. (And the conversation you linked to certainly shows there is more to consider than these rarer cases like mine)

Thank you.

1 Like

IMO for the time being simply declare your function as returning a Variant and then either return your proper type or null on error. Nothing more, nothing less. This is your code so just make it a convention to have Variant functions like these that can return null or a type.

If you need to return more than one type depending on the error conditions your code is either too complex and needs to be split up or return an Enum on error.

Actually this test worked pretty well for a nullable return type.

extends Node2D

var Queue: Array[Vector2i] = []

class ChunkCandidate:
	var coords: Vector2i
	var is_valid: bool
	func _init(_coords: Vector2i = Vector2i(0, 0), _is_valid: bool = false):
		coords = _coords
		is_valid = _is_valid


func _ready() -> void:
	# Vector list is empty so expect a problem
	var example_1: ChunkCandidate = get_something_from_vector_list()
	print(example_1.is_valid)
	# prints false
	
	# Now list is not empty so expect a Vector2i
	Queue.append(Vector2i(10, 100))
	var example_2: ChunkCandidate = get_something_from_vector_list()
	print(example_2.is_valid)
	print(example_2.coords)
	# prints true
	# prints (10, 100)
	


func get_something_from_vector_list() -> ChunkCandidate:
	if Queue.is_empty():
		return ChunkCandidate.new(Vector2i(0, 0), false)
	else:
		return ChunkCandidate.new(Queue.pop_front(), true)

I am implementing it in my code by creating a standalone class in a utils folder called ChunkCandidate and then using that as the return value for my chunk queues.

Now my chunk queue looks like this and will not fail on an empty queue:

func get_next_action_in_queue() -> ChunkCandidate:
	if Queue.is_empty():
		return ChunkCandidate.new(Vector2.ZERO, false)
	return ChunkCandidate.new(Queue.pop_front(), true)

And the return values can now be tested for is_valid before attempting to use the Vector2i.

With thanks to @wchc for this brilliant solution. I hope that helps the OP too.

1 Like

Do you actually want your program to carry on after an this error?
If not then just force quit (instead of returning):

printerr("Malfunction at this junction") 
assert(false)
2 Likes

This is a good point, nearly always just want to quit on these errors so why am trying to return anything at all. Thanks for the moment of clarity!

1 Like

But keep in mind that an assert only works in a debug version and not in a release build.

2 Likes

And bear in mind there are times when you actually want to return null, that it is not an error. Using a sentinel value is not always so easy.

1 Like