3 min read

Typing My Way Out of GDScript Trouble

Typing My Way Out of GDScript Trouble

I had been aiming for a few more game mechanics to be complete this week, but what started out as tidying up a small chunk of code has led to several days of bug hunting and refactoring.

Although the majority of the code base uses Static Typing, there was quite a lot of code that was carried over from prototyping into production that was a bit more loose with how it handled things.

The thing that initiated the mass refactoring was removing a function that I thought was no longer needed (as no references where found when using the Find All References function in VSCode), only to find it was still in use. As the instance of the function being called was not on a statically typed object, it was never picked up and ultimately led to a runtime error waiting to be found.

The place this happened was in a for * in loop and upon reviewing more of these loops, I began realising that I had no idea what the data type was that I was dealing with in several of them.

I also came to realise that any loops where I was checking types was hugely repetitive and basically just boiler plate code, i.e.:

for node in $NamedNode.get_children():
  if node is SomeType:
    do_whatever_is_needed(node)

or:

for node in $NamedNode.get_children():
  var n := node as SomeType
  if n:
    do_whatever_is_needed(n)

I decided I'd replace instances of this with a helper function I created (for_each_child) to loop through a specified node's children, do the type checking, and then fire the callback:

class_name Globals
extends Node


static func script_is_type_of(script: Script, type: String) -> bool:
	if script.get_global_name() == type:
		return true

	var base_script: Script = script.get_base_script()

	if base_script:
		return script_is_type_of(base_script, type)
	else:
		return false


static func for_each_child(obj: Node, node_type: String, callback: Callable) -> void:
	for child in obj.get_children():
		if child.is_class(node_type):
			callback.call(child)
			continue

		var script: Script = child.get_script()
		if script and script_is_type_of(script, node_type):
			callback.call(child)

Using this function changes the first code sample to read like this instead:

Globals.for_each_child($NamedNode, "SomeType", do_whatever_is_needed)

Alternatively, if I wanted something inline rather than to pass the child node to another function, I could use a lambda:

Globals.for_each_child(
  $NamedNode,
  "SomeType",
  func (node: SomeType) -> void:
    do_the_thing(node)
    do_another_thing(node)
)

It can create more lines of code when using lambdas for inline blocks, but it has helped a lot when refactoring blocks like this to now be able to see clearly what types I am working with.

Additionally, I enabled some extra warnings (found in Project Settings > Debug > GDScript) to make it easier to notice instances of operations that may not be type safe; namely:

  • Unsafe Property Access: produces a warning or an error respectively when accessing a property whose presence is not guaranteed at compile-time in the class.
  • Unsafe Method Access: produces a warning or an error respectively when calling a method whose presence is not guaranteed at compile-time in the class.
  • Unsafe Cast: produces a warning or an error respectively when a Variant value is cast to a non-Variant.
  • Unsafe Call Argument: produces a warning or an error respectively when using an expression whose type may not be compatible with the function parameter expected.
  • Untyped Declaration: produces a warning or an error respectively when a variable or parameter has no static type, or if a function has no static return type.

These warnings unfortunately can't fully detect context, so even if something is guarded with a if obj is ExpectedType statement, it will treat obj as whatever it's declared type is and warn accordingly.

If you're working on something with a notable amount of code, I'd personally recommend enabling these settings and ensuring nothing is left untyped; it was a tough week fixing up all the warnings after the fact!