Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Game Development with Godot & GDScript  Module 10 – Static typing, debugging and testing

Static typing and the warning system

Type GDScript variables, functions and collections, set warning levels in project.godot, make unsafe access safe and measure what typing speeds up.

  • Intermediate
  • 15 minutes
  • Examples run with godot 4.7.2-stable and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Type variables, parameters, return values, arrays and dictionaries, and predict what := infers
  • Configure GDScript warning levels in project.godot and make a build fail on them
  • Replace unsafe property and method access with typed variables and checked casts
  • Measure which loops typing speeds up and which it leaves unchanged

Before you start

What you’ll learn · Before you start

What you will learn

  • Type variables, parameters, return values, arrays and dictionaries, and predict what := infers
  • Configure GDScript warning levels in project.godot and make a build fail on them
  • Replace unsafe property and method access with typed variables and checked casts
  • Measure which loops typing speeds up and which it leaves unchanged

Before you start

On this page

Godot 4.7.2 knows 49 warnings about GDScript code, and a headless run on a build machine prints none of the 37 that only warn. A script that drops half a coin in a division, with a variable it never uses, passes --check-only with exit status 0. Types written into the code, and warnings raised to errors, are how a project makes Godot refuse such lines before the game starts.

This page types an enemy from top to bottom, sets the levels that decide what Godot only mentions and what it refuses, turns the unsafe lines of a script into checked ones, and measures what typing does to speed. Every run below is Godot 4.7.2.

Types written down let the parser check a line before it runs

enemy.gd gives everything it holds a static type: A type fixed for a variable when it is declared, as in var hp: int or var hp := 100. Godot then refuses any line that would store another type, with a parse error before the script runs. More in “Variables, types and operators”: hp: int written out, speed := 4.5 taken from its first value by type inference: Letting Godot take a variable's type from its first value, written :=. var speed := 4.5 makes speed a float for good, and var steps := 4 makes steps an int. More in “Variables, types and operators”, a typed array of patrol points, a typed dictionary of loot, and a type for every parameter and return value. fight.gd uses it:

enemy.gd: every member, parameter and return value typed GD · typed/enemy.gd
class_name Enemy
extends Node2D

var hp: int = 30
var speed := 4.5
var patrol: Array[Vector2] = [Vector2(0, 0), Vector2(64, 0), Vector2(64, 32)]
var loot: Dictionary[String, int] = {"coin": 3, "gem": 1}


func take_hit(damage: int) -> int:
	hp = maxi(hp - damage, 0)
	return hp


func route_length() -> float:
	var total := 0.0
	for i: int in range(1, patrol.size()):
		total += patrol[i - 1].distance_to(patrol[i])
	return total
fight.gd: one hit, the route and the loot GD · typed/fight.gd
extends SceneTree


func _init() -> void:
	var grunt := Enemy.new()
	print("hp after a hit of 12: ", grunt.take_hit(12))
	print("route length: ", grunt.route_length(), " px")
	for item: String in grunt.loot:
		print(item, " x", grunt.loot[item])
	print("speed is a ", type_string(typeof(grunt.speed)))
	grunt.speed = 3
	print("after speed = 3: ", grunt.speed, ", still a ", type_string(typeof(grunt.speed)))
	grunt.free()
	quit()

Three scripts in mistakes/ each break one promise. wrong_argument.gd passes damage := "12" to take_hit(); infer_from_dictionary.gd writes var hp := stats["hp"] where stats is a plain Dictionary; infer_from_json.gd writes var data := JSON.parse_string(text). The session runs fight.gd, then parses the three with --check-only, which reads a script and stops before running any of it, then runs declared.gd, which fixes both inferences:

wrong_argument.gd: a String where take_hit() wants an int GD · typed/mistakes/wrong_argument.gd
extends SceneTree


func _init() -> void:
	var grunt := Enemy.new()
	var damage := "12"
	grunt.take_hit(damage)
	grunt.free()
	quit()
declared.gd: the two inferences, with the types written GD · typed/mistakes/declared.gd
extends SceneTree


func _init() -> void:
	var stats: Dictionary[String, int] = {"hp": 30, "armour": 2}
	var hp := stats["hp"]
	var data: Variant = JSON.parse_string('{"hp": 30}')
	print("hp is an ", type_string(typeof(hp)), ": ", hp)
	print("data is a ", type_string(typeof(data)), ": ", data)
	quit()
Typed code, then three mistakes refused before they run, Godot 4.7.2 Python · typing_session.py
from session import files, run

# A fresh copy of the project: one import registers class_name Enemy, then the typed scripts run and the three
# mistakes are parsed with --check-only.
files("project.godot", "typed/enemy.gd=enemy.gd", "typed/fight.gd=fight.gd", "typed/mistakes=mistakes")
run("""
godot --headless --quiet --import
godot --headless --no-header --script fight.gd
!godot --headless --no-header --check-only --script mistakes/wrong_argument.gd
!godot --headless --no-header --check-only --script mistakes/infer_from_dictionary.gd
!godot --headless --no-header --check-only --script mistakes/infer_from_json.gd
godot --headless --no-header --script mistakes/declared.gd
""")

Output

$ godot --headless --quiet --import
$ godot --headless --no-header --script fight.gd
hp after a hit of 12: 18
route length: 96.0 px
coin x3
gem x1
speed is a float
after speed = 3: 3.0, still a float
$ godot --headless --no-header --check-only --script mistakes/wrong_argument.gd
SCRIPT ERROR: Parse Error: Invalid argument for "take_hit()" function: argument 1 should be "int" but is "String".
          at: GDScript::reload (res://mistakes/wrong_argument.gd:7)
ERROR: Failed to load script "res://mistakes/wrong_argument.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script mistakes/infer_from_dictionary.gd
SCRIPT ERROR: Parse Error: Cannot infer the type of "hp" variable because the value doesn't have a set type.
          at: GDScript::reload (res://mistakes/infer_from_dictionary.gd:6)
ERROR: Failed to load script "res://mistakes/infer_from_dictionary.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script mistakes/infer_from_json.gd
SCRIPT ERROR: Parse Error: The variable type is being inferred from a Variant value, so it will be typed as Variant. (Warning treated as error.)
          at: GDScript::reload (res://mistakes/infer_from_json.gd:5)
ERROR: Failed to load script "res://mistakes/infer_from_json.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --script mistakes/declared.gd
hp is an int: 30
data is a Dictionary: { "hp": 30.0 }

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typing_session.py

fight.gd printed 18 hp after a hit of 12, a route of 96 px, and a float for speed, because 4.5 is one; given the int 3, speed held 3.0 and stayed a float. Each mistake was refused at its own line, before a single line ran. What the parser knows from each kind of declaration:

Declaration What the parser knows What it refuses
var hp: int = 30 hp is an int for good storing a String in it
var speed := 4.5 inferred: a float for good storing a String in it; an int becomes a float
func take_hit(damage: int) -> int what goes in and what comes out take_hit("12")
Array[Vector2], Dictionary[String, int] the type of every element an element of another type
var hp := stats["hp"], stats a plain Dictionary nothing: the value has no set type the line itself, Cannot infer the type
var data := JSON.parse_string(text) a Variant: The type that can hold a value of any built-in type, an object or null. Untyped variables, parameters and array slots hold Variants, so the type of what they hold is known only when the line runs. More in “Arrays and typed arrays” the line, through the warning inference_on_variant

:= needs a value whose type the parser knows. A value from a plain dictionary has none, and a function declared to return Variant, as JSON.parse_string() is, gives only Variant, which the default warning settings refuse to infer. declared.gd shows both ways out: a typed dictionary, after which stats["hp"] is an int, or the type written by hand, var data: Variant, which says on purpose that the value may be anything.

Version note

A typed loop variable, for item: String in loot, needs Godot 4.2 or later, as does the warning untyped_declaration. Typed dictionaries, Dictionary[String, int], need Godot 4.4 or later.

Checkpoint

Check: Typed declarations and what := infers

  1. Question 1 of 2 With the default warning settings of Godot 4.7.2, which of these lines does the parser refuse?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: var data := JSON.parse_string(text)

    := needs a value whose type the parser knows. A Variant has no particular type, so either write the type or use a typed source, such as a Dictionary[String, int].

  2. Question 2 of 2 enemy.gd declares func take_hit(damage: int) -> int. A script writes var damage := "12" and then grunt.take_hit(damage). When does Godot report the mistake?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When the script is parsed, before any of its lines runs

    Typing both the variable and the parameter moves the error from the playtest to the moment the script is parsed.

Each warning has a level, and level 2 makes it an error

The parser also reports lines that are legal but suspicious, as warnings. coins.gd shares 7 coins between 2 players with coins / 2 and declares a bonus it never uses. Every warning is a project setting under debug/gdscript/warnings/, and its warning level: The setting that decides what Godot does with one kind of GDScript warning: 0 ignores it, 1 reports it in the editor and 2 makes it a parse error. Each warning has its own, under debug/gdscript/warnings/ in project.godot. decides what happens: 0 ignores it, 1 warns, 2 makes it an error. list_warnings.gd counts the warnings of Godot 4.7.2 by their default level; the session then tries coins.gd five ways, the last two in strict/, whose project.godot raises both warnings to 2:

coins.gd: an integer division and an unused variable GD · warnings/coins.gd
extends SceneTree


func _init() -> void:
	var coins := 7
	var bonus := 3
	print("each of 2 players gets ", coins / 2, " coins")
	quit()
check.sh: a check that fails when Godot reports an error Shell · warnings/check.sh
#!/bin/sh
# Parses one script and fails when Godot reports an error: --check-only itself exits with status 0 either way.
out=$(godot --headless --no-header --check-only --script "$1" 2>&1)
printf '%s\n' "$out"
if printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then
	echo "check.sh: $1 has errors"
	exit 1
fi
echo "check.sh: $1 is clean"
49 warnings, then coins.gd run, parsed and made strict, Godot 4.7.2 Python · warnings_session.py
from session import files, run

# The warnings Godot 4.7.2 knows and their default levels, then coins.gd (two warnings at their default level, Warn)
# run, parsed, and parsed with the local debugger on (-d), which prints warnings; then in strict/, whose project.godot
# raises both warnings to Error, parsed again, its exit status printed, and checked by check.sh.
files("project.godot", "warnings/list_warnings.gd=list_warnings.gd", "warnings/coins.gd=coins.gd",
      "warnings/strict/project.godot=strict/project.godot", "warnings/coins.gd=strict/coins.gd",
      "warnings/check.sh=strict/check.sh")
run("""
godot --headless --no-header --script list_warnings.gd
godot --headless --no-header --script coins.gd
godot --headless --no-header --check-only --script coins.gd
godot --headless --no-header -d --check-only --script coins.gd
cd strict
!godot --headless --no-header --check-only --script coins.gd; echo "exit status: $?"
!sh check.sh coins.gd
""")

Output

$ godot --headless --no-header --script list_warnings.gd
49 warnings
Ignore by default: 8
Warn by default: 37
Error by default: 4
Ignore: untyped_declaration, inferred_declaration, unsafe_property_access, unsafe_method_access, unsafe_cast, unsafe_call_argument, return_value_discarded, missing_await
Error: inference_on_variant, native_method_override, get_node_default_without_onready, onready_with_export
$ godot --headless --no-header --script coins.gd
each of 2 players gets 3 coins
$ godot --headless --no-header --check-only --script coins.gd
$ godot --headless --no-header -d --check-only --script coins.gd
WARNING: The local variable "bonus" is declared but never used in the block. If this is intended, prefix it with an underscore: "_bonus".
     at: GDScript::reload (res://coins.gd:6)
WARNING: Integer division. Decimal part will be discarded.
     at: GDScript::reload (res://coins.gd:7)
$ cd strict
$ godot --headless --no-header --check-only --script coins.gd; echo "exit status: $?"
SCRIPT ERROR: Parse Error: The local variable "bonus" is declared but never used in the block. If this is intended, prefix it with an underscore: "_bonus". (Warning treated as error.)
          at: GDScript::reload (res://coins.gd:6)
SCRIPT ERROR: Parse Error: Integer division. Decimal part will be discarded. (Warning treated as error.)
          at: GDScript::reload (res://coins.gd:7)
ERROR: Failed to load script "res://coins.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
exit status: 0
$ sh check.sh coins.gd
SCRIPT ERROR: Parse Error: The local variable "bonus" is declared but never used in the block. If this is intended, prefix it with an underscore: "_bonus". (Warning treated as error.)
          at: GDScript::reload (res://coins.gd:6)
SCRIPT ERROR: Parse Error: Integer division. Decimal part will be discarded. (Warning treated as error.)
          at: GDScript::reload (res://coins.gd:7)
ERROR: Failed to load script "res://coins.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
check.sh: coins.gd has errors
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py

Step through what happened to the division at each level:

Interactive · stage by stage What Godot does with one warning Step through the division in coins.gd. The sub-label of each stage says what happened to the warning there.
What Godot does with one warning, step 6 of 6. Showing print(coins / 2), The parser flags it, Its level is looked up, Level 1 reports it (the run printed 3 coins), Level 2 refuses it (exit status still 0). In focus: Level 2 refuses it.level 0: droppedprint(coins / 2)an int divided by an intThe parser flags itwarning integer_divisionIts level is looked upproject.godot or defaultLevel 1 reports itthe run printed 3 coinsLevel 2 refuses itexit status still 0

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The line. coins.gd shares 7 coins between 2 players with coins / 2. Both values are ints, so the result is 3 and the half coin is dropped, which may or may not be what the game wants.
  2. The parser. Before anything runs, the parser reads the script and flags the division as integer_division, one of the 49 warnings Godot 4.7.2 knows. It flags bonus too, a variable declared and never used.
  3. The level. Each warning has a level: 0 Ignore, 1 Warn, 2 Error. Godot reads debug/gdscript/warnings/integer_division from project.godot, or takes the default, which is 1 for this warning.
  4. Level 0. At level 0 the warning is dropped, and nothing anywhere mentions it. Eight warnings start there, among them untyped_declaration and the unsafe_* warnings of member access, calls and casts.
  5. Level 1. At level 1 the script runs as written. The script editor lists the warning in its status bar, but a headless run and --check-only printed no warning; in a terminal only -d, the local debugger, printed it.
  6. Level 2. At level 2 the warning becomes a parse error that ends with (Warning treated as error.), and the script refuses to load. --check-only printed both errors and still ended with exit status 0.

Three facts of the run matter on a build machine. At level 1 the headless run and a plain --check-only printed no warning at all: those warnings show in the editor’s status bar, and in a terminal only with -d, which starts the local debugger and printed both. At level 2 the same file was refused, with (Warning treated as error.) after each message. And --check-only still ended with exit status 0, so a pipeline must read what it printed, as check.sh does by looking for SCRIPT ERROR.

Trusting the exit status of --check-only

godot --headless --check-only --script coins.gd && echo ok prints ok even when the script does not parse, because the command ends with status 0 either way. Capture its output and fail when it contains SCRIPT ERROR, as check.sh does: on the same file it ended with exit status 1.

The strict file sets each level under [debug], as gdscript/warnings/integer_division=2; the editor shows the same settings in Project Settings, under Debug and GDScript, with Advanced Settings on. @warning_ignore("integer_division") above a line silences that warning on that line, and @warning_ignore_start with @warning_ignore_restore does it for a region. Scripts under res://addons get no warnings at all: the default directory_rules setting excludes that folder, so third-party add-ons do not flood your list.

Key idea

A warning at level 1 is only a message in the editor; level 2 makes the parser refuse the script everywhere, and even then --check-only exits with status 0, so a build has to read its output.

Checkpoint

Check: Warning levels and strict projects

  1. Question 1 of 2 integer_division is at its default level, 1. Which command prints the warning about coins / 2 in a terminal?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: godot --headless --no-header -d --check-only --script coins.gd

    At level 1 a warning is a message for the editor's status bar and the debugger. Only level 2 makes it an error that every run prints.

  2. Question 2 of 2 A build job runs godot --headless --check-only --script coins.gd in the strict copy and fails only when the exit status is not 0. What does the job do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It passes, although Godot refused to load the script

    Capture what Godot prints and fail when it contains SCRIPT ERROR, as check.sh does.

Unsafe access is where the parser has to trust you

get_node() returns a Node, whatever the node really is. In arena.gd the grunt is reached as an untyped found, as node: Node and through found as Enemy. All of it works while the game runs, yet the parser can check none of it: Node has no take_hit() and no hp, so a typo in either name would surface only when the line runs. That is unsafe access: Using a property or method through a type that does not declare it, such as take_hit() on a variable typed Node. The parser cannot check such a line, so a typo shows up only when it runs; the unsafe_* warnings report it.. Four unsafe_* warnings flag it, one each for property access, method access, call arguments and casts, and like untyped_declaration they start at level 0, so nothing says a word by default. arena_safe.gd casts the node once and uses the typed variable from then on. The session runs both in a relaxed copy of the project, parses both in a strict copy that raises those five warnings to 2, and then runs wrong_node.gd, where the node named Grunt is a plain Node2D:

arena.gd: the grunt reached without its type GD · unsafe/arena.gd
extends SceneTree


func knock_back(target: Enemy) -> void:
	target.position.x += 16


func _initialize() -> void:
	var grunt := Enemy.new()
	grunt.name = "Grunt"
	root.add_child(grunt)
	var found = root.get_node("Grunt")
	var node: Node = root.get_node("Grunt")
	node.take_hit(4)
	print("hp: ", node.hp)
	knock_back(found)
	var cast := found as Enemy
	print("x: ", cast.position.x)
	quit()
arena_safe.gd: one checked cast, then typed access GD · unsafe/arena_safe.gd
extends SceneTree


func knock_back(target: Enemy) -> void:
	target.position.x += 16


func _initialize() -> void:
	var grunt := Enemy.new()
	grunt.name = "Grunt"
	root.add_child(grunt)
	var enemy := root.get_node("Grunt") as Enemy
	if enemy == null:
		push_error("Grunt is not an Enemy")
		quit(1)
		return
	enemy.take_hit(4)
	print("hp: ", enemy.hp)
	knock_back(enemy)
	print("x: ", enemy.position.x)
	quit()
wrong_node.gd: a cast and a typed assignment on the wrong node GD · unsafe/wrong_node.gd
extends SceneTree
## The node named Grunt is a plain Node2D this time. The cast with as gives null without a word; the typed
## assignment in typed_grunt() stops that function with an error, and typed_grunt() returns null.


func typed_grunt() -> Enemy:
	var grunt: Enemy = root.get_node("Grunt")
	return grunt


func _initialize() -> void:
	var stand_in := Node2D.new()
	stand_in.name = "Grunt"
	root.add_child(stand_in)
	var cast := root.get_node("Grunt") as Enemy
	print("as Enemy gave: ", cast)
	var typed := typed_grunt()
	print("typed_grunt() gave: ", typed)
	quit()
Two arenas run and parsed strictly, then a wrong node, Godot 4.7.2 Python · unsafe_session.py
from session import files, run

# Two copies of one project: relaxed/ with the default warnings, strict/ whose project.godot raises the four unsafe_*
# warnings of member access, calls and casts, and untyped_declaration, to Error. arena.gd reaches the Grunt through
# untyped and Node-typed variables; arena_safe.gd casts it to Enemy once. Both run in relaxed/, then strict/ parses
# both. Last, wrong_node.gd runs in relaxed/ with a plain Node2D named Grunt: as gives null, a typed assignment stops.
for folder, settings in (("relaxed", "project.godot"), ("strict", "unsafe/strict/project.godot")):
    files(f"{settings}={folder}/project.godot", f"typed/enemy.gd={folder}/enemy.gd",
          f"unsafe/arena.gd={folder}/arena.gd", f"unsafe/arena_safe.gd={folder}/arena_safe.gd")
files("unsafe/wrong_node.gd=relaxed/wrong_node.gd")
run("""
cd relaxed
godot --headless --quiet --import
godot --headless --no-header --script arena.gd
godot --headless --no-header --script arena_safe.gd
cd ../strict
godot --headless --quiet --import
!godot --headless --no-header --check-only --script arena.gd
godot --headless --no-header --check-only --script arena_safe.gd
cd ../relaxed
!godot --headless --no-header --script wrong_node.gd
""")

Output

$ cd relaxed
$ godot --headless --quiet --import
$ godot --headless --no-header --script arena.gd
hp: 26
x: 16.0
$ godot --headless --no-header --script arena_safe.gd
hp: 26
x: 16.0
$ cd ../strict
$ godot --headless --quiet --import
$ godot --headless --no-header --check-only --script arena.gd
SCRIPT ERROR: Parse Error: Variable "found" has no static type. (Warning treated as error.)
          at: GDScript::reload (res://arena.gd:12)
SCRIPT ERROR: Parse Error: The method "take_hit()" is not present on the inferred type "Node" (but may be present on a subtype). (Warning treated as error.)
          at: GDScript::reload (res://arena.gd:14)
SCRIPT ERROR: Parse Error: The property "hp" is not present on the inferred type "Node" (but may be present on a subtype). (Warning treated as error.)
          at: GDScript::reload (res://arena.gd:15)
SCRIPT ERROR: Parse Error: The argument 1 of the function "knock_back()" requires the subtype "Enemy" but the supertype "Variant" was provided. (Warning treated as error.)
          at: GDScript::reload (res://arena.gd:16)
SCRIPT ERROR: Parse Error: Casting "Variant" to "Enemy" is unsafe. (Warning treated as error.)
          at: GDScript::reload (res://arena.gd:17)
ERROR: Failed to load script "res://arena.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script arena_safe.gd
$ cd ../relaxed
$ godot --headless --no-header --script wrong_node.gd
as Enemy gave: <null>
SCRIPT ERROR: Trying to assign value of type 'Node2D' to a variable of type 'enemy.gd'.
          at: typed_grunt (res://wrong_node.gd:7)
          GDScript backtrace (most recent call first):
              [0] typed_grunt (res://wrong_node.gd:7)
              [1] _initialize (res://wrong_node.gd:17)
typed_grunt() gave: <null>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unsafe_session.py

Both scripts printed hp 26 and x 16.0, so their behaviour is the same. The strict copy refused five lines of arena.gd and none of arena_safe.gd:

Line of arena.gd Warning at level 2 The safe form
var found = root.get_node("Grunt") untyped_declaration var enemy := root.get_node("Grunt") as Enemy
node.take_hit(4) unsafe_method_access enemy.take_hit(4)
print("hp: ", node.hp) unsafe_property_access enemy.hp
knock_back(found) unsafe_call_argument knock_back(enemy)
var cast := found as Enemy unsafe_cast cast the Node, which has a type, not a Variant

wrong_node.gd met a node of the wrong type both ways. The cast with as gave <null> without a word, as the static typing page warns, which is why arena_safe.gd tests the result and reports the mismatch with push_error(). The typed assignment, var grunt: Enemy = root.get_node("Grunt"), stopped on its own line with Trying to assign value of type ‘Node2D’ to a variable of type ‘enemy.gd’, which is why the page calls that form the more reliable one for @onready variables: the mistake shows where it is made.

The global functions follow the same pattern: the static typing page lists abs(), clamp(), round() and five more as not statically typed, and their typed counterparts, such as absf(), clampi() and roundi(), keep a line safe.

Checkpoint

Check: Unsafe access and checked casts

  1. Question 1 of 2 node is declared var node: Node = root.get_node("Grunt"), and unsafe_method_access is at 2. Which line does the parser refuse?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: node.take_hit(4)

    A member used through a type that does not declare it is unsafe access. Cast once to the class that has it, then use the typed variable.

  2. Question 2 of 2 The node named Grunt turns out to be a plain Node2D. What do var a := root.get_node("Grunt") as Enemy and var b: Enemy = root.get_node("Grunt") do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: a becomes null without a word; the line of b stops with an error

    Test the result of as before using it, or use the typed assignment when a wrong node type is a bug that should stop the game at once.

Typing speeds up arithmetic, and not every line

The static typing page says typed code uses optimised opcodes, the engine’s instructions for an operation, when the types are known as the script is compiled. typed_bench.gd puts numbers on that: five loops of 200,000 passes, each written typed and untyped, run 41 times in turn, keeping the fastest run of each so that a busy moment of the computer spoils one run, not the result:

typed_bench.gd: five loops, typed and untyped GD · bench/typed_bench.gd
extends SceneTree
## Times five loops of 200,000 passes, typed and untyped. Each pair runs 41 times, typed and untyped in turn, and
## the fastest run of each is printed, so that a busy moment of the computer spoils one run rather than the result.

const N := 200_000
const RUNS := 41


class Mover:
	var speed: float = 0.0


func float_typed() -> void:
	var x: float = 1.0
	for i: int in N:
		x = x * 1.000001 + 0.5


func float_untyped():
	var x = 1.0
	for i in N:
		x = x * 1.000001 + 0.5


func int_typed() -> void:
	var total: int = 0
	for i: int in N:
		total += i


func int_untyped():
	var total = 0
	for i in N:
		total += i


func vector_typed() -> void:
	var v: Vector2 = Vector2.ZERO
	var step: Vector2 = Vector2(0.5, 0.25)
	for i: int in N:
		v += step * 0.5


func vector_untyped():
	var v = Vector2.ZERO
	var step = Vector2(0.5, 0.25)
	for i in N:
		v += step * 0.5


func node_typed(node: Node2D) -> void:
	for i: int in N:
		node.rotation = 0.5


func node_untyped(node):
	for i in N:
		node.rotation = 0.5


func member_typed(mover: Mover) -> void:
	for i: int in N:
		mover.speed = 0.5


func member_untyped(mover):
	for i in N:
		mover.speed = 0.5


func fastest_ms(job: Callable, best: float) -> float:
	var start := Time.get_ticks_usec()
	job.call()
	return minf(best, (Time.get_ticks_usec() - start) / 1000.0)


func _init() -> void:
	var node := Node2D.new()
	var mover := Mover.new()
	var cases: Array[Array] = [
		["float maths", float_typed, float_untyped],
		["int sum", int_typed, int_untyped],
		["Vector2 maths", vector_typed, vector_untyped],
		["Node2D.rotation writes", node_typed.bind(node), node_untyped.bind(node)],
		["script member writes", member_typed.bind(mover), member_untyped.bind(mover)],
	]
	print("Godot %s, 200,000 passes, fastest of %d runs" % [Engine.get_version_info().string, RUNS])
	for row: Array in cases:
		var typed_ms := INF
		var untyped_ms := INF
		for r: int in RUNS:
			typed_ms = fastest_ms(row[1], typed_ms)
			untyped_ms = fastest_ms(row[2], untyped_ms)
		print("%-23s typed %5.2f ms   untyped %5.2f ms   untyped/typed %.2f" % [row[0], typed_ms, untyped_ms, untyped_ms / typed_ms])
	node.free()
	quit()
Typed and untyped loops timed, Godot 4.7.2 Python · bench_session.py
from session import files, run

# typed_bench.gd times typed and untyped loops; the times depend on the computer and change from run to run.
files("project.godot", "bench/typed_bench.gd=typed_bench.gd")
run("""
godot --headless --no-header --script typed_bench.gd
""")

Output

$ godot --headless --no-header --script typed_bench.gd
Godot 4.7.2-stable (official), 200,000 passes, fastest of 41 runs
float maths             typed  4.03 ms   untyped  5.56 ms   untyped/typed 1.38
int sum                 typed  2.92 ms   untyped  3.95 ms   untyped/typed 1.35
Vector2 maths           typed  4.16 ms   untyped  6.30 ms   untyped/typed 1.52
Node2D.rotation writes  typed 11.87 ms   untyped 11.99 ms   untyped/typed 1.01
script member writes    typed  6.55 ms   untyped  6.50 ms   untyped/typed 0.99

This output changes from run to run: the times depend on the computer and change from run to run

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bench_session.py

On the computer that recorded this run, typed float, int and Vector2 maths ran 1.35 to 1.5 times as fast as the same loops untyped. Writing a node’s rotation and a script’s member variable took the same time typed or untyped, within 1 %: the gain came from the arithmetic the loop does itself, and none from the writes into another object. So type everything for the checks of the first three sections, and when a frame is slow, measure the loop that costs the time before rewriting it for speed; the debugger and profiler lesson of this module shows how.

Checkpoint

Check: What typing speeds up

  1. One question In the recorded run of typed_bench.gd, which of these loops gained least from typing?

    Choose one answer.

    Show the answer: One question

    Answer: Writing Node2D.rotation

    Typed opcodes speed up the operations the script performs itself, so the gain depends on what the loop does.

Practise: type a slime for a strict project

slime.gd works, and nothing in it is typed. Type it so that it passes --check-only in a project that raises untyped_declaration and those four unsafe_* warnings to 2, and so that it behaves exactly as before.

  1. Make a folder with the files below: the task’s project.godot, slime.gd and the checker check_slime.gd.
  2. Type every variable, parameter, return value and signal argument, and give targets and drops element types.
  3. In a terminal there, run godot --headless --no-header --check-only --script slime.gd until it prints nothing, then godot --headless --no-header --script check_slime.gd.
project.godot: the strict settings of the task GODOT · practice/project.godot
config_version=5

[application]

config/name="Strict slime"

[debug]

gdscript/warnings/untyped_declaration=2
gdscript/warnings/unsafe_property_access=2
gdscript/warnings/unsafe_method_access=2
gdscript/warnings/unsafe_cast=2
gdscript/warnings/unsafe_call_argument=2
slime.gd: the starter, with no types GD · practice/slime.gd
extends Node2D
## A slime that splits in two when it dies. Type every variable, parameter and return value, and the two
## collections, so that the script passes --check-only in this strict project and behaves as before.

signal split(count)

var hp = 40.0
var regen_per_second = 0.5
var targets = []
var drops = {"gel": 2, "seed": 1}


## Takes damage and returns the hit points left; at 0 or below, emits split once with 2.
func take_hit(amount):
	if hp <= 0:
		return hp
	hp -= amount
	if hp <= 0:
		split.emit(2)
	return hp


## Regains regen_per_second for each second, never above 40.
func regenerate(seconds):
	hp = min(hp + regen_per_second * seconds, 40.0)


## The point of targets closest to the slime, or Vector2.INF when there is none.
func nearest():
	var best = Vector2.INF
	var best_distance = INF
	for point in targets:
		var distance = position.distance_to(point)
		if distance < best_distance:
			best_distance = distance
			best = point
	return best


## How many items the slime drops in all.
func loot_total():
	var total = 0
	for item in drops:
		total += drops[item]
	return total
check_slime.gd: the checker, typed for the same strict project GD · practice/check_slime.gd
extends SceneTree
## Checks res://slime.gd: its behaviour, then that every declaration in it has a static type. It runs in the same
## strict project, so it is fully typed too: values that come back as Variant go into typed variables first.

var passed := 0
var failed := 0


func check(ok: bool, what: String) -> void:
	if ok:
		passed += 1
		print("PASS ", what)
	else:
		failed += 1
		print("FAIL ", what)


func make() -> Node2D:
	var slime_script: GDScript = load("res://slime.gd")
	return slime_script.new()


func _init() -> void:
	var slime := make()
	var left: float = slime.call("take_hit", 15.5)
	check(is_equal_approx(left, 24.5), "take_hit(15.5) leaves 24.5 hp (got %s)" % left)
	slime.free()

	slime = make()
	var splits: Array[int] = []
	slime.connect("split", func(count: int) -> void: splits.append(count))
	for i: int in 3:
		slime.call("take_hit", 30.0)
	check(splits == [2], "three hits of 30 emit split(2) once (got %s)" % [splits])
	slime.free()

	slime = make()
	slime.set("hp", 39.0)
	slime.call("regenerate", 1.0)
	var hp: float = slime.get("hp")
	check(is_equal_approx(hp, 39.5), "regenerate(1.0) from 39 gives 39.5 hp (got %s)" % hp)
	slime.call("regenerate", 10.0)
	hp = slime.get("hp")
	check(is_equal_approx(hp, 40.0), "regenerate(10.0) stops at 40 hp (got %s)" % hp)
	slime.free()

	slime = make()
	var near: Vector2 = slime.call("nearest")
	check(near == Vector2.INF, "nearest() with no targets is Vector2.INF")
	var targets: Array[Vector2] = [Vector2(30, 40), Vector2(6, 8)]
	slime.set("targets", targets)
	near = slime.call("nearest")
	check(near == Vector2(6, 8), "nearest() picks (6, 8) from the origin (got %s)" % near)
	var loot: int = slime.call("loot_total")
	check(loot == 3, "loot_total() is 3 (got %d)" % loot)
	slime.free()

	var slime_script: Script = load("res://slime.gd")
	check_types(slime_script)
	print("%d of %d checks passed" % [passed, passed + failed])
	quit(0 if failed == 0 else 1)


func check_types(slime_script: Script) -> void:
	var untyped := PackedStringArray()
	var element_types := PackedStringArray()
	for property: Dictionary in slime_script.get_script_property_list():
		var name: String = property.name
		var usage: int = property.usage
		var hint: String = property.hint_string
		if usage & PROPERTY_USAGE_SCRIPT_VARIABLE and usage & PROPERTY_USAGE_NIL_IS_VARIANT:
			untyped.append("var " + name)
		if name in ["targets", "drops"] and hint != "":
			element_types.append(name)
	for method: Dictionary in slime_script.get_script_method_list():
		var method_name: String = method.name
		var result: Dictionary = method.return
		var result_usage: int = result.usage
		if result_usage & PROPERTY_USAGE_NIL_IS_VARIANT:
			untyped.append(method_name + "() return")
		for arg: Dictionary in method.args:
			var arg_usage: int = arg.usage
			if arg_usage & PROPERTY_USAGE_NIL_IS_VARIANT:
				untyped.append("%s(%s)" % [method_name, arg.name])
	for sig: Dictionary in slime_script.get_script_signal_list():
		for arg: Dictionary in sig.args:
			var arg_type: int = arg.type
			if arg_type == TYPE_NIL:
				untyped.append("signal %s(%s)" % [sig.name, arg.name])
	check(untyped.is_empty(), "every declaration has a static type (untyped: %s)" % ", ".join(untyped))
	check(element_types.size() == 2, "targets and drops have element types (typed: %s)" % ", ".join(element_types))

The checker tests the slime’s behaviour, then reads the script’s own list of members, functions and signals to find anything left untyped. On the starter, on a finished slime and on a tempting one whose hit points are an int:

The strict check and the checker on three slimes, Godot 4.7.2 Python · check_session.py
from session import files, run

# The task's strict project three times: with the untyped starter, with a finished slime.gd and with a tempting one
# whose hit points are an int. In each, --check-only parses slime.gd, then check_slime.gd tests it.
for folder, slime in (("starter", "practice/slime.gd"), ("finished", "practice/solution/slime.gd"), ("tempting", "practice/tempting/slime.gd")):
    files(f"practice/project.godot={folder}/project.godot", f"practice/check_slime.gd={folder}/check_slime.gd",
          f"{slime}={folder}/slime.gd")
run("""
cd starter
!godot --headless --no-header --check-only --script slime.gd 2>&1 | grep "SCRIPT ERROR" | head -n 3
godot --headless --no-header --check-only --script slime.gd 2>&1 | grep -c "SCRIPT ERROR"
cd ../finished
godot --headless --no-header --check-only --script slime.gd
godot --headless --no-header --script check_slime.gd
cd ../tempting
godot --headless --no-header --check-only --script slime.gd
!godot --headless --no-header --script check_slime.gd
""")

Output

$ cd starter
$ godot --headless --no-header --check-only --script slime.gd 2>&1 | grep "SCRIPT ERROR" | head -n 3
SCRIPT ERROR: Parse Error: Parameter "count" has no static type. (Warning treated as error.)
SCRIPT ERROR: Parse Error: Variable "hp" has no static type. (Warning treated as error.)
SCRIPT ERROR: Parse Error: Variable "regen_per_second" has no static type. (Warning treated as error.)
$ godot --headless --no-header --check-only --script slime.gd 2>&1 | grep -c "SCRIPT ERROR"
18
$ cd ../finished
$ godot --headless --no-header --check-only --script slime.gd
$ godot --headless --no-header --script check_slime.gd
PASS take_hit(15.5) leaves 24.5 hp (got 24.5)
PASS three hits of 30 emit split(2) once (got [2])
PASS regenerate(1.0) from 39 gives 39.5 hp (got 39.5)
PASS regenerate(10.0) stops at 40 hp (got 40.0)
PASS nearest() with no targets is Vector2.INF
PASS nearest() picks (6, 8) from the origin (got (6.0, 8.0))
PASS loot_total() is 3 (got 3)
PASS every declaration has a static type (untyped: )
PASS targets and drops have element types (typed: targets, drops)
9 of 9 checks passed
$ cd ../tempting
$ godot --headless --no-header --check-only --script slime.gd
$ godot --headless --no-header --script check_slime.gd
FAIL take_hit(15.5) leaves 24.5 hp (got 25.0)
PASS three hits of 30 emit split(2) once (got [2])
FAIL regenerate(1.0) from 39 gives 39.5 hp (got 39.0)
PASS regenerate(10.0) stops at 40 hp (got 40.0)
PASS nearest() with no targets is Vector2.INF
PASS nearest() picks (6, 8) from the origin (got (6.0, 8.0))
PASS loot_total() is 3 (got 3)
PASS every declaration has a static type (untyped: )
PASS targets and drops have element types (typed: targets, drops)
7 of 9 checks passed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py

The starter broke 18 rules. The tempting slime passed --check-only, because every declaration had a type, and still failed two checks: an int cannot hold 24.5 hp, and half a hit point of regeneration was cut to 0. The strict settings catch missing types, never a wrong one; behaviour tests catch that. When your slime prints 9 of 9 checks passed, try the questions below: they cover the four sections above, beginning with whichever your answers show you are least sure of.

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 What does var hp: int = 30 promise?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: hp holds an int for as long as it exists, and the parser refuses lines that would store anything else

    The type is part of the declaration: hp: int, or hp := 30 to infer it from the value.

  2. Question 2 of 12 var speed := 4.5 is followed by speed = 3. What does speed hold afterwards?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: 3.0, still a float

    := fixes the type at the first value. Later values must fit it, and an int fits a float.

  3. Question 3 of 12 Why does var hp := stats["hp"] parse when stats is a Dictionary[String, int], and fail when stats is a plain Dictionary?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: A typed dictionary tells the parser that every value is an int; a plain one says nothing about its values

    Give the collection its types, or write the variable's type, var hp: int = stats["hp"].

  4. Question 4 of 12 What does level 2 do to a GDScript warning?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: It turns the warning into a parse error, so the script refuses to load

    The three levels are 0 Ignore, 1 Warn and 2 Error, one setting per warning under debug/gdscript/warnings/.

  5. Question 5 of 12 Your project raises untyped_declaration to 2, and a third-party add-on in res://addons is untyped. What happens to the add-on scripts?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Nothing; the default directory_rules setting excludes res://addons from warnings

    The reference advises keeping that default rule, so third-party plugins are left alone, and adding an Include rule for each add-on of your own that should get warnings.

  6. Question 6 of 12 integer_division is at 2 in your project, and one division in a tile loop is meant to drop the fraction. What keeps the project strict and accepts that line?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: @warning_ignore("integer_division") on the line above it

    @warning_ignore_start and @warning_ignore_restore do the same for a region of several lines.

  7. Question 7 of 12 node is typed Node and holds your Enemy. Why is node.take_hit(4) unsafe access?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Node does not declare take_hit(), so the parser cannot check the call or its arguments

    Unsafe access is a member used through a type that does not declare it.

  8. Question 8 of 12 In a new Godot 4.7.2 project, does anything report node.take_hit(4) on a variable typed Node?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: No; unsafe_method_access starts at level 0, Ignore

    Raise untyped_declaration and the unsafe_* warnings yourself when you want a project where every access is checked.

  9. Question 9 of 12 hp is a float and a line says hp = clamp(hp + regen, 0.0, 40.0). Which replacement keeps the line statically typed, as the static typing page suggests?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: hp = clampf(hp + regen, 0.0, 40.0)

    The page lists typed counterparts for abs(), ceil(), clamp(), floor(), lerp(), round(), sign() and snapped(), such as absf(), clampi() and lerpf().

  10. Question 10 of 12 According to the static typing page, why can typed GDScript run faster?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: It uses optimised opcodes when the types of operands and arguments are known when the script is compiled

    Known types let GDScript pick an instruction for that exact operation instead of one that must handle any type.

  11. Question 11 of 12 A loop adds up 200,000 float damage values every frame, and all its variables are untyped. What did the benchmark suggest typing them would do?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Make the loop about 1.4 times as fast

    Arithmetic on known types is where typing paid off, by 1.35 to 1.5 times in the recorded run.

  12. Question 12 of 12 A frame takes 30 ms, and a teammate plans to speed it up by typing every property write in _process(). What should you expect, and do first?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Little or no gain from the writes; measure which code takes the time before rewriting it

    Type everything for the checks; for speed, find the slow code first and change that.

Interview questions

Warm-up: what is the difference between var hp = 30, var hp: int = 30 and var hp := 30?

var hp = 30 declares an untyped variable: it holds a Variant, so a later hp = "full" is accepted and errors appear only when a line uses the value. var hp: int = 30 gives hp the static type int for good, so the parser refuses any line that would store something else, such as a String. var hp := 30 infers the same type, int, from the first value. := fails when the value has no type the parser knows: a value of a plain dictionary gives the parse error Cannot infer the type, and a function that returns Variant, such as JSON.parse_string(), is refused by the warning inference_on_variant, which is at level 2 by default. Then write the type, or type the collection.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Your build machine runs godot --headless --check-only on every changed script and has never failed, yet a script that does not parse reached the main branch. What went wrong, and how do you change the job so that it catches this and the warnings your team cares about?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    You join a game with 300 untyped scripts, and playtests keep stopping on errors such as a method missing on a Node. How would you move the project to strict typing without stopping feature work?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Types on variables, parameters, return values and collections let the parser refuse a wrong line before the game runs; := infers a type only from a value whose type the parser knows.
  • Every GDScript warning has a level in project.godot, 0 Ignore, 1 Warn or 2 Error; level 1 shows only in the editor, or in a terminal with -d, and level 2 makes the warning a parse error.
  • --check-only ends with exit status 0 even when a script does not parse, so a build must read its output for SCRIPT ERROR.
  • Unsafe access, a member used through a type that does not declare it, is flagged by the unsafe_* warnings once they are raised, and a checked cast with as, or a typed variable, makes the line safe.
  • Typing sped up float, int and Vector2 arithmetic about 1.35 to 1.5 times and left property writes unchanged, so type everything for the checks and measure before optimising for speed.

Terms in this lesson

unsafe access
Using a property or method through a type that does not declare it, such as take_hit() on a variable typed Node. The parser cannot check such a line, so a typo shows up only when it runs; the unsafe_* warnings report it.
warning level
The setting that decides what Godot does with one kind of GDScript warning: 0 ignores it, 1 reports it in the editor and 2 makes it a parse error. Each warning has its own, under debug/gdscript/warnings/ in project.godot.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.