DEV Community

Cover image for Static Typing in GDScript: The 30 Minutes That Saved Me 30 Hours
Ziva
Ziva

Posted on

Static Typing in GDScript: The 30 Minutes That Saved Me 30 Hours

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:

  1. Wrong type to a function: move_to(player.position_string) when position_string does not exist on player.
  2. Wrong return type: a function declared -> int that returns a String somewhere down a branch.
  3. Misspelled property names: player.helath instead of player.health.
  4. Stale method signatures: you renamed attack(target) to attack(target, damage) six months ago and the editor finds the one call site you missed.
  5. Null misuses: assigning a null to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Every new function declaration starts with a return type. Even -> void. The editor refuses to autocomplete properly without it.
  2. Every var either gets a type annotation or uses := for inference. Bare var x = ... is now a code smell I look for in PRs.
  3. 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)