Last month I spent an evening adding type hints to a 4,000-line GDScript codebase that had been running fine for a year. I expected nothing. By the time I finished, the editor had flagged 12 latent bugs I had never noticed: wrong return types, methods called with stale signatures, a Vector2 being passed where the function expected Vector3. Every single one of those would have eventually crashed in production.
Three of them already had: I just blamed them on something else when the bug reports came in.
Static typing in GDScript is one of those features that sounds boring on the docs page and turns out to be the biggest quality-of-life upgrade you can make to a Godot 4 project. It is faster too. Independent benchmarks put the gain at 28-59% on hot paths, driven by the engine bypassing Variant dispatch when types are known at compile time.
This post is the case for using type hints everywhere, including the gotchas I hit, and how to retrofit a typed style into a codebase that does not have it.
What static typing actually catches
Static typing in GDScript is opt-in per declaration. You can mix typed and untyped code freely in the same file. The type system catches:
-
Wrong type to a function:
move_to(player.position_string)whenposition_stringdoes not exist onplayer. -
Wrong return type: a function declared
-> intthat returns aStringsomewhere down a branch. -
Misspelled property names:
player.helathinstead ofplayer.health. -
Stale method signatures: you renamed
attack(target)toattack(target, damage)six months ago and the editor finds the one call site you missed. -
Null misuses: assigning a
nullto a non-nullable typed variable.
The first three I catch every time I run the project. The last two I never catch in dynamic GDScript because they only blow up under specific game state.
I added types using the official syntax:
# Before (untyped, "I'll figure it out at runtime")
var hp = 100
var enemies = []
func damage(amount, multiplier):
hp -= amount * multiplier
return hp <= 0
# After (typed)
var hp: int = 100
var enemies: Array[Enemy] = []
func damage(amount: int, multiplier: float = 1.0) -> bool:
hp -= int(amount * multiplier)
return hp <= 0
Note the Array[Enemy]. Typed arrays are a Godot 4 feature and they catch passing the wrong kind of array into a function that expects Array[Player]. This single check found four bugs in my project where I had been silently mixing entity types.
The performance angle
I would argue for typing on bug-catching grounds alone. The performance angle is a free bonus that turns out to be substantial.
The beep.blog benchmark ran 1 billion iterations across common GDScript operations on an M2 Max:
| Operation | Untyped (ns) | Typed (ns) | Speedup |
|---|---|---|---|
| Integer addition | 22 | 14 | 1.57x |
| Vector2 add | 62 | 30 | 2.07x |
| Method call | 47 | 33 | 1.42x |
| Engine API call | 73 | 51 | 1.43x |
The speedup comes from typed GDScript generating optimized opcodes that skip Variant unwrapping. In dynamic GDScript, every operation has to: check the type tag, dispatch to the right operator, perform the operation, wrap the result back in a Variant. Typed code knows the types ahead of time and just does the operation.
The biggest wins are in vector math and engine API calls, which is exactly what game code does in _process and _physics_process. If your game has any per-frame work, typing the hot loops is a free 30%+ speedup with no algorithm changes.
The catch with AI-generated code
I run a lot of code through LLMs while working in Godot. The pattern I have noticed: most cloud LLMs default to dynamic GDScript even when the surrounding file is typed. They generate this:
func get_nearest_enemy(pos):
var closest = null
var closest_dist = 999999
for e in enemies:
if e.position.distance_to(pos) < closest_dist:
closest = e
closest_dist = e.position.distance_to(pos)
return closest
When the rest of the file looks like this:
func get_nearest_enemy(pos: Vector2) -> Enemy:
var closest: Enemy = null
var closest_dist: float = INF
for e: Enemy in enemies:
var d: float = e.position.distance_to(pos)
if d < closest_dist:
closest = e
closest_dist = d
return closest
The dynamic version compiles. It even runs. But it skips every benefit you set up the typed style for: no IDE autocomplete on the return value, no type-mismatch checks, no opcode optimization. Worse, the next person editing the file has to mentally track which variables are which type.
This is one reason game-dev-specific tools beat generic chat assistants. Tools like Ziva (an AI agent built into the Godot editor) read your existing code style before generating new code, so a typed file gets typed completions. Generic assistants train on whatever GDScript samples were on the open web at scrape time, which skews dynamic.
If you are using a generic assistant, the workaround is to put the typed signature in the prompt: "write get_nearest_enemy(pos: Vector2) -> Enemy: that does X". The constraint is enough to flip the generation style.
How to enforce typing in your project
Godot has a project setting for warnings on untyped declarations. Turn the relevant ones on:
Project Settings -> Debug -> GDScript:
- "Untyped Declaration": Warn
- "Inferred Declaration": Warn
- "Unsafe Method Access": Error
- "Return Value Discarded": Warn
Set "Treat Warnings as Errors" if you want hard enforcement. I treat untyped declarations as warnings only because mixing typed and untyped is occasionally pragmatic. You might want a generic helper that takes any value. But unsafe method access on untyped variables (the kind that crashes at runtime) is always an error.
Allen Pestaluky has a deeper guide on each warning and what to set it to.
What I changed in my workflow
After the typing audit I made three habit changes:
- Every new function declaration starts with a return type. Even
-> void. The editor refuses to autocomplete properly without it. - Every
vareither gets a type annotation or uses:=for inference. Barevar x = ...is now a code smell I look for in PRs. - Class members at the top of a script always have explicit types. Inference inside functions is fine; inference on class state means the next reader has to scroll to the assignment to figure out what the type is.
The cost of these habits is roughly one extra word per declaration. The benefit is a compiler that finds bugs before I do.
If you maintain a Godot 4 project that has not had a typing pass, give it one evening. The bugs you find will pay for the time. The performance will be a nice surprise.
Top comments (0)