I recently finished writing a fairly significant amount of code—implementing a system first in TypeScript, and then porting that same system to GDScript so it can run on Godot (I have a reason to need both). This has given me a really thorough impression of many aspects of GDScript as a programming language.
I've said in the past that I think if you're going to use Godot—which is certainly the best engine for a lot of projects now, and definitely a strict improvement from Unity unless you are very invested in that ecosystem—the best thing you can do is give GDScript a shot. I think a lot of us have recollections of things like UnityScript or even ActionScript, which may make one assume that GDScript is a "toy" language. It really isn't; it's definitely svelte in terms of features—closer to Lua than to JavaScript—but it's very far from being underbaked, and it has a lot of selling points—as well as some caveats.
The Good
GDScript is designed specifically to be used as the high-level language for implementing game logic in a video game. This is something that's rather unique; virtually every other language used for game development was originally intended to solve other problems: Lua for industrial automation, C# for taking over Java's niche in server applications and enterprise software, JavaScript for enabling supply chain attacks, C++ for getting rid of your remaining toes, etc.
This means that GDScript has some useful features that are missing from other high-level languages. It has native Vector2 and Vector3 types. Unlike JavaScript, it distinguishes integers and floating-point numbers. The standard library includes things like an actually-useful PRNG (again, unlike JavaScript). It has a match statement that's inherently very useful for implementing complicated if-then-else behavioral logic of the kind video games have all the time. Unlike Lua, it natively supports packed arrays. The standard library includes many functions that are widely used in game dev: lerp(), smoothstep(), wrap(), etc.
It's also very tightly coupled to the engine in ways that simply speed up development. Godot's system of annotations mean that it's trivial to write a node class that comes with an editor panel that allows for easily configuring objects in the editor. The semantics of reading resource files at runtime are made much simpler and more consistent through preload() and load(); preload() is notably something you can't really get with other languages in Godot.
GDScript's treatment of signals as a first-class concept is incredibly useful for most game programming; "thing that has to hear about it when other thing does something" is one of the most common requirements for implementing any kind of game system.
The language also has no garbage collector, which is extremely unique among high-level languages. I think the Jon Blowesque position that all automatic memory management is bad is silly. But it is true that GCs have somewhat unpredictable performance characteristics and tend to create stuttering, which is very much not ideal for a video game.
GDScript instead uses a combination of manual memory management and reference counting. Manual memory management is actually the norm, and it's something that 90% of the time you don't have to think about; most objects that get allocated in Godot are scene tree nodes, and nodes simply free themselves when they leave the scene tree (after freeing their children). The assumption "an object attached to the scene tree needs to stay in memory, an object out of the scene tree can go away" is obviously very useful in the context of this kind of game engine, but it's something you can't really use to your advantage without using a language that has manual memory management as a default.
Reference counting on the other hand is dumbly simple, and is the default for objects that don't exist as scene tree nodes. It does require a marginal amount of thinking about it; Godot does not implement some general way of solving cyclical references, which are the general problem with reference counting. But does provide a weakref() function, so it's very easy to let dependent objects store weak references to their parents on the assumption that they, too, are just going to free when the parent frees and thus kills their last reference.
C# has more language features outright but losing out on both the tight integration to the engine, and having C#'s garbage collector running in the background, are both significant downsides.
Finally, this isn't really an upside of the language itself, but using GDScript lets you use Godot's debugger, which is an extremely useful tool.
The Bad
GDScript is a dynamic language with incremental typing—meaning you can write type hints to get some compile type checking. This feature is underbaked, basically unfinished, and writing statically-typed code in GDScript is kind of a halfway affair. Consider the following code:
var doubled_str = function (n: int) -> String: return str(2 * n)
var numbers: Array[int] = [1, 2, 3]
var words: Array[String] = numbers.map(doubled_str)
You'd expect that words would then contain ["2", "4", "6"]. But no, this code actually throws a type error, because map(), no matter what, returns an untyped array and GDScript can't assign that to the typed array variable. You have to manually cast the array by running it through the typed array constructor:
var words = Array(numbers.map(doubled_str), TYPE_STRING, "", null)
...but this, as far as I can tell, doesn't actually work with things like inner classes; it's both verbose and kind of dumb, and is downstream of the fact that functions, in GDScript, don't have specific types. In most languages with good type systems, you can specify a type like (eg, TypeScript):
type NumDescriber = (n: number) => string
Functions, when passed around as values, have a type that reflects their arguments and return value. Map, for example, looks something like this:
function map<T,U> (cb: (elem: T) => U) => U[]
GDScript has no conception of this at all; every function is just a generic Callable object. Typing for composite types is extremely limited in general; while you can specify the key and value types for a dictionary (hash map), you again can't specify inner types for nested objects. You can't say, eg, Array[Array[int]]; the language is generally geared towards the idea that any data structure more complicated than a very simple hash map should be its own class and handle its data in an OOP way, which isn't always appropriate. Many structs that in the TypeScript codebase are interfaces have, in the GDScript port, become classes.
GDScript's static typing system is analogous to TypeScript's in function; it's a way of adding compile-time checking as a layer on top of an otherwise dynamic language. But TypeScript has a lot of sophisticated "narrowing" logic to determine the type of values at different points in the code based on cues like if (typeof x === "string"). GDScript's narrowing logic is nowhere near as smart.
There are no union types or discriminated unions, which has been a very noticeable limitation. The TypeScript project I ported relies heavily on unions of primitive types; in GDScript, all of those become Variants, and there's really no hope of doing anything else with them unless I want to marshall them into some sort of container class, which wouldn't really address the issue and would create large numbers of pointless heap allocations.
Generally speaking, I don't think you can write statically-typed GDScript, not really; the dynamic nature of the language always creeps in. I'd actively suggest not using typed collections in most contexts, because the feature is overall fairly underbaked. In most places where a typed collection makes sense, you'll probably be using a packed array anyway.
The type system is really the one aspect of GDScript that I think is both 1. bad, 2. can be improved on. A nice thing about GDScript's nature as a language that's joined at the hip to the engine is that it doesn't have to care about backwards compatibility; you're never going to try to run your Godot 4.x projects in Godot 5.0, so GDScript 5.0 could break compatibility with any number of new features or breaking changes. This makes the language substantially more "fixable" than most languages, just as a practical matter, so I do have hope that it could evolve.
The Ugly
By "the ugly parts", I mean things that I think are bad out of personal preference—or just things that are warty and incomplete.
Mostly, I have gripes with GDScript's syntax. I dislike indentation languages out of principle, for one thing; I don't think the extra steps needed to parse them are worth not having to type } or end, and I find the overall concept fussy. They also tend to make any kind of nested structure confusing. Writing a callback that's more than one line long in GDScript is pure gore:
ary.map(func (e: String) -> int:
# ...do various things with e...
return e)
The syntax is overall verbose in unpleasant ways all over, sometimes because it's missing useful syntactic sugar from other languages. Lambdas in general are fussy; js arrow syntax gives (a ,b) => a + b while the equivalent in GDScript is func (a, b): return a + b, nearly twice as long. GDScript doesn't have destructuring assignment like js (let {id, mass} = item), nor Lua's multi assignment (local x, y += dx, dy).
Working with dictionaries is made slightly less unaesthetic by GDScript's support of both JSON-style ({"key": "value"}) and Lua-style ({ key = "value" }) style, but I still long for js's structuring assignment construct, where if you have local vars named foo and bar, you can just write return { foo, bar } and it does what you think it does.
GDScript also lacks a spread operator, so constructions that are func(...args) in js become the gory func.callv(args) in GDScript. The language enforces this idea that you can call a function that's in scope, but a function object you get as a value can only be called through the .call() and .callv() methods on Callable objects, which feels annoyingly fussy.
You can't define inner functions either—you have to assign a lambda to a variable and then .call() that.
It generally lacks language constructs for working with immutable objects. In js code that's meant to be stateless, you write a lot of things like:
return {
...oldStruct,
id, weight, timesUpdated
};
While this kind of thing isn't always the most performant, it's a very useful tool; GDScript really doesn't play nice with this pattern, for many reasons.
The combination of the janky type system, annoying-to-use lambdas, aversion to immutability, and generally fussy approach to writing any sort of higher-order function combine to make any sort of functional programming unnecessarily frictional in GDScript. The language is, understandably¸ mostly geared towards an OOP-procedural model of programming and that works well enough for a lot of problems. But not every problem. I'm very used to true multi-paradigm languages where you can dip into a functional style where it makes sense, which it often does! Many computations are better expressed through map() and reduce(). GDscript's arrays don't even provide flatmap(), let alone zip().
This is all stuff that's not really as much of a blocker as the type system. The type system really is in a state where people who want static types are not going to be satisfied with it, while people who think static typing is a sort of dog cone for programmers are going to find it chafing. Not having flatmap or slightly fussy syntax, you can live with—though, again, the language can change in breaking ways for Godot 5. Just adopt Lua-style syntax. Bring back end. You know you want to. The language even lacks ++ and -- operators, a feature inherited from Lua (where they'd clash with -- being the comment syntax) even though GDScript's comments start with # like in Python!