Jacob Coughenour
All Posts
Abode Aerobat Reader [LICENSE EXPIRED] - posts/gdscript-fake-generics/index_final.pdf

Faking generics in GDScript with GDScript

A Godot plush sitting on top of a graphics card surrounded by lego plants

ICYMI: The Godot Plushie is available again for a limited time. All revenue made will directly benefit the Godot Development Fund. Get it here: https://www.makeship.com/products/godot-robot-v2-plushie

Static typing in GDScript is great for improving the performance and maintainability of your code. The problem is it’s not as complete as similar languages.

One of the issues I have been running into is the lack of typed nullables. So if we have a function that could either return null or Vector2 , we can’t set the return type to Vector2 .

1func get_player_position(player_id: int) -> Vector2:
2	if _data.has(player_id):
3		return _data[player_id].position
4	# player not found
5	return null

The editor gives us an error on line 5: Cannot return a value of type "null" as "Vector2"

Instead of returning null, let’s return Vector2.ZERO:

1func get_player_position(player_id: int) -> Vector2:
2	if _data.has(player_id):
3		return _data[player_id].position
4	# player not found
5	return Vector.ZERO

Now when we call it we have to remember to check if the value is Vector2.ZERO to know that the player wasn’t found.

1var p = get_player_position(2) # Vector2
2if p != Vector.ZERO:
3	var p_vec2 = p as Vector2
4	print(p_vec2.distance_to(_local_player))

But this now assumes that Vector2(0, 0) is never a valid position which is not true. This is the kind of thing that would introduce a hard to reproduce bug into our code.

The only way around this is to make the return type Variant or Object which defeats the whole purpose of defining the return type in the first place.

1func get_player_position(player_id: int) -> Variant:
2	if _data.has(player_id):
3		return _data[player_id].position
4	# player not found
5	return null

So now if we try to use our function we are always going to have to null check it then cast it back to a Vector2 just so we can get the code completion option for the distance_to() method in the editor.

1var p = get_player_position(2) # Variant
2if p != null:
3	var p_vec2 = p as Vector2
4	print(p_vec2.distance_to(_local_player))

From what I can tell, this is the official way Godot wants use to handle this scenario.

Ideally, I want something like this:

1func get_player_position(player_id: int) -> Optional[Vector2]:
2	if _data.has(player_id):
3		return _data[player_id].position
4	# player not found
5	return null

Then it would simplify how we handle the return value.

1var p = get_player_position(2) # Optional[Vector2]
2if p != null:
3	# infer that p is Vector2 now
4	print(p.distance_to(_local_player))

In this example, I’m assuming that it would internally wrap the value and do a bit of type inference to know that name isn’t null after my null check.

Generics

If you are coming from another language, your instinct would be to make a generic type that would wrap the value and still retain the type for when you try to unwrap it. A while back, Godot added typed arrays and typed dictionaries. So now you can specify the type for the items in an array like this:

1var a: Array[int] = [1, 2, 3]

You might assume now that GDScript has a way of defining generic types since it supports type parameters for Array and Dictionary , but it doesn’t. There is currently no way for me to make Optional[T] valid syntax in GDScript without heavily modifying the engine.

Faking it

When you think about it, we don’t need our Optional to work with all the possible variable types. Let’s just make a version of it that only works with Vector2 .

 1class_name OptionalVector2
 2extends RefCounted
 3
 4var _has_value: bool
 5var _value: Vector2
 6
 7func has_value() -> bool:
 8	return _has_value
 9
10func set_value(p_value: Vector2) -> void:
11	_has_value = true
12	_value = p_value
13
14func value() -> Vector2:
15	return _value
16
17static func empty() -> OptionalVector2:
18	return OptionalVector2.new()
19
20static func with(p_value: Vector2) -> OptionalVector2:
21	var p = OptionalVector2.new()
22	p.set_value(p_value)
23	return p

Now we can refactor our get_player_position method to use it:

1func get_player_position(player_id: int) -> OptionalVector2:
2	if _data.has(player_id):
3		return OptionalVector2.with(_data[player_id].position)
4	# player not found
5	return OptionalVector2.empty()
6# ...
7var name = get_player_name(2)
8if name.has_value():
9	print(name.value().capitalize())

The key here is that value() returns a Vector2 while offering a way to check if the value is actually valid. So this fixes our problem where we didn’t know if Vector2(0, 0) was valid or not.

Also, if we didn’t check has_value() and our OptionalVector2 was empty, value() would give us a Vector2(0, 0) since that is the default value of a Vector2 .

Now let’s make an OptionalVector3.

 1class_name OptionalVector3
 2extends RefCounted
 3
 4var _has_value: bool
 5var _value: Vector3
 6
 7func has_value() -> bool:
 8	return _has_value
 9
10func set_value(p_value: Vector3) -> void:
11	_has_value = true
12	_value = p_value
13
14func value() -> Vector3:
15	return _value
16
17static func empty() -> OptionalVector3:
18	return OptionalVector3.new()
19
20static func with(p_value: Vector3) -> OptionalVector3:
21	var p = OptionalVector3.new()
22	p.set_value(p_value)
23	return p

If you compare it to OptionalVector2, it’s almost identical except for all the 2s now being 3s. We can’t just make a base class and call it a day because we can’t override the methods with mismatched parameters and return types.

We will just have to copy paste and modify for all the Optional types we want as we need them. Hopefully we don’t find a bug in our implementation and have to manually go back and fix it in all the copies…

Wait a minute. Why don’t we just write some code to write the code for us?

Automating it

I threw together a quick editor plugin to automate the creation of these classes. The way it works is pretty simple. It adds an abstract class call ScriptGenerator and then looks for any scripts in your project that implement it. When you run the Run Script Generators command in the editor, each script generator generates files that get put in _generated_ folders. Then you can use those classes in your code base.

Here is my OptionalGenerator:

 1extends ScriptGenerator
 2
 3class OptionalTypeSettings:
 4	var typeName: String
 5	var valueType: String
 6	var filename: String
 7	
 8	func _init(p_typeName, p_valueType, p_filename):
 9		typeName = p_typeName
10		valueType = p_valueType
11		filename = p_filename
12	
13	func vars():
14		return {
15			"typeName": typeName,
16			"valueType": valueType,
17			"filename": filename
18		}
19
20func get_source_data() -> Array:
21	return [
22		OptionalTypeSettings.new("OptionalInt", "int", "int"),
23		OptionalTypeSettings.new("OptionalBool", "bool", "bool"),
24		OptionalTypeSettings.new("OptionalString", "String", "string"),
25		OptionalTypeSettings.new("OptionalDictionary", "Dictionary", "dictionary"),
26		# register new types here
27	]
28
29func get_file_name(source_data: Variant) -> String:
30	return "optional_" + source_data.filename
31	
32func generate_source(source_data: Variant) -> String:
33	return """extends RefCounted
34class_name {typeName}
35
36var _has_value: bool
37var _value: {valueType}
38
39func has_value() -> bool:
40	return _has_value
41
42func set_value(val: {valueType}) -> void:
43	_has_value = true
44	_value = val
45
46func value() -> {valueType}:
47	return _value
48
49static func empty() -> {typeName}:
50	return {typeName}.new()
51
52static func with(val: {valueType}) -> {typeName}:
53	var p = {typeName}.new()
54	p.set_value(val)
55	return p
56""".format(source_data.vars())

When I run the command it will generate me OptionalInt, OptionalBool, OptionalString, and OptionalDictionary. If I want to make an OptionalVector2, all I have to do is add it at line 29 then run the generate command again.

I put the code up on github if you want to try it out. I might put it up on the AssetLib/Asset Store at some point. I’ve included some other example generators in the repo.

I’m curious if anyone else has done something like this with gdscript before. I’m only emulating generic types now but I could see some other applications for this addon like generating fully typed models for my sqlite save file.

Bluesky - Thread
https://bsky.app/profile/jacobcoughenour.com/post/3m3dywcwstc2x