A Deep Dive into Type Systems — What Static Typing Protects You From
Static vs dynamic typing, structural vs nominal typing, type inference, generics, and null safety, compared across TypeScript, Go, Rust, Java, and Python with interactive diagrams.
The type system is one of the things people weigh when choosing a programming language. But "static or dynamic" doesn't capture all the ways languages differ. They also differ in whether compatibility is judged by structure or by name, how much they infer, how they implement generics, and how they handle null. TypeScript, Go, Rust, Java, and Python each make different choices on these four points.
What Is a Type System?
A type system is a set of rules that classifies values in a program and prevents invalid operations. Dividing a string or calling a method that doesn't exist are the kinds of bugs it catches, either before or during execution.
Benjamin Pierce defines type systems in his book Types and Programming Languages (commonly known as TaPL):
A type system is a tractable syntactic method for proving the absence of certain program behaviors by classifying phrases according to the kinds of values they compute.
Static Typing vs Dynamic Typing
Static Typing
With static typing, types are checked at compile time, so type errors show up before the program runs.
// Go: Type errors detected at compile time
func add(a int, b int) int {
return a + b
}
func main() {
// add("hello", "world") // Compile error: cannot use "hello" (string) as int
result := add(1, 2) // OK
fmt.Println(result)
}Static typing has these benefits:
- Bugs surface earlier (CI catches type errors just by compiling)
- IDEs can use type information for completion and refactoring
- Type annotations tell readers what a function accepts and returns
Representative languages include Go, Rust, Java, C++, and Haskell.
Dynamic Typing
With dynamic typing, types are checked at runtime. Variables don't have types; values do.
# Python: Type errors occur at runtime
def add(a, b):
return a + b
print(add(1, 2)) # 3 — OK
print(add("hello", "!")) # "hello!" — works as string concatenation
# print(add(1, "hello")) # TypeError: unsupported operand type(s) at runtimeDynamic typing has these benefits:
- Code is shorter without annotations, so prototypes come together quickly
- Any value with the needed methods can be passed, whatever its type (duck typing)
- You can start writing code before learning about types
Representative languages include Python, Ruby, JavaScript, and PHP.
Gradual Typing
Gradual typing lets static and dynamic typing coexist in one language. Type annotations can be added a little at a time, and unannotated parts behave as dynamically typed.
// TypeScript: Types can be added gradually
function greet(name: string): string {
return `Hello, ${name}!`;
}
// Using any behaves like dynamic typing (not recommended)
function legacy(x: any) {
return x.foo.bar; // Not type-checked
}TypeScript is designed as a superset of JavaScript, so you can add types to an existing JavaScript codebase piece by piece. Python also gained type hints via PEP 484, which external tools like mypy check statically.
Structural Typing vs Nominal Typing
Statically typed languages also split into two camps on how type compatibility is determined.
Structural Typing
Type compatibility is determined by structure (shape). Regardless of type names, a value with the required properties or methods is compatible.
// TypeScript: Structural typing
interface Printable {
toString(): string;
}
class Dog {
constructor(public name: string) {}
toString() { return `Dog: ${this.name}`; }
}
// Dog doesn't explicitly implement Printable,
// but it has toString(), so it's compatible
function print(p: Printable) {
console.log(p.toString());
}
print(new Dog("Buddy")); // OK — structure matchesGo interfaces also use structural typing:
// Go: Interfaces are implicitly implemented (structural typing)
type Stringer interface {
String() string
}
type Dog struct {
Name string
}
// Dog doesn't explicitly declare Stringer,
// but it has a String() method, so it satisfies Stringer
func (d Dog) String() string {
return "Dog: " + d.Name
}
func Print(s Stringer) {
fmt.Println(s.String())
}Nominal Typing
Type compatibility is determined by type names (declarations). Even if the structure is identical, differently declared types are incompatible.
// Java: Nominal typing
class Dog {
String name;
int age;
}
class Cat {
String name;
int age;
}
// Dog and Cat have the same fields, but they're incompatible
Dog dog = new Dog();
// Cat cat = dog; // Compile error: incompatible typesRust also uses nominal typing. Structs with identical fields still need an explicit conversion.
struct Meters(f64);
struct Kilometers(f64);
// Both wrap f64, but they're different types
let m = Meters(1000.0);
// let km: Kilometers = m; // Compile error: mismatched typesWrapping an existing type in a new named type like this is called the newtype pattern. Both hold an f64, but mixing up Meters and Kilometers is a compile error, so values with different meanings, such as distances, currencies, and IDs, can be kept apart by type.
Which Is Better?
Neither wins outright; they suit different situations. The ratings in the table compare the two approaches with each other: flexibility reflects whether types can become compatible without a declaration, and safety reflects whether the compiler prevents unintended compatibility.
| Aspect | Structural Typing | Nominal Typing |
|---|---|---|
| Flexibility | High (retroactive compatibility) | Lower (explicit declarations required) |
| Safety | Somewhat lower (unintended compatibility) | Higher (explicit type distinction) |
| Best for | API boundaries, external data | Domain modeling, separating values with different meanings |
Type Inference
Type inference is the ability of the compiler to work out types from expressions without annotations.
Type Inference Step by Step
Watch how TypeScript infers types from expressions without explicit annotations.
let x = 42;
x: numberInfers x as number from literal 42
Type Inference Capabilities by Language
How much a compiler can infer varies greatly across languages.
TypeScript infers the types of object arrays and even callback return values.
// Complex types inferred without annotations
const users = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
];
// users: { name: string; age: number }[] inferred
const oldest = users.reduce((prev, curr) =>
prev.age > curr.age ? prev : curr
);
// oldest: { name: string; age: number } inferredRust fixes a variable's type from how it is used later.
// Type determined from usage context
let mut v = Vec::new(); // At this point: Vec<_> (type undetermined)
v.push(1); // Now determined as Vec<i32>
// Closure argument types are also inferred
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();Rust's type inference is local and constraint-based, built on Hindley-Milner ideas. Unlike global Hindley-Milner, it also has to satisfy Rust-specific constraints from traits, lifetimes, coercions, and method resolution. Function signatures must carry type annotations, which keeps APIs readable and inference results predictable.
Go mainly infers the types of variables declared with :=.
// Type inference with :=
x := 42 // inferred as int
s := "hello" // inferred as string
m := map[string]int{"a": 1} // inferred as map[string]int
// Function parameters and return types require explicit annotations
func add(a, b int) int { // <-- not inferred
return a + b
}Go deliberately limits type inference. In its answer on why the compiler cannot infer some type arguments for generic code, the Go FAQ says: "Type inference is intentionally limited to ensure that there is never any confusion as to which type is inferred. Experience with other languages suggests that unexpected type inference can lead to considerable confusion when reading and debugging a program."
Generics (Parametric Polymorphism)
Generics are a mechanism for abstracting over types as parameters. They allow reusing the same logic for different types.
TypeScript Generics
// T is a type parameter
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // num: number | undefined
const str = first(["a", "b"]); // str: string | undefinedGo Generics (1.18+)
// Type parameters introduced in Go 1.18
func First[T any](s []T) (T, bool) {
if len(s) == 0 {
var zero T
return zero, false
}
return s[0], true
}
// Type constraints can require methods or operators
type Number interface {
~int | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}Go's generics, introduced in 1.18, express type constraints through interfaces. The tilde syntax ~int accepts all types whose underlying type is int.
When generics first landed, Go had a restriction: methods could not declare their own type parameters, so a generic operation that belonged to a type still had to be written as a package-level function. Go 1.27 relaxed this, allowing method declarations to introduce their own type parameters (for example math/rand/v2 now declares (*Rand) N[Int intType](Int) Int). Interface methods still may not declare type parameters, and generic methods cannot be used to satisfy an interface.
Rust Generics + Trait Bounds
// Trait bounds impose requirements on type parameters
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in &list[1..] {
if item > largest {
largest = item;
}
}
largest
}Rust generics are expanded into a copy per concrete type at compile time through monomorphization, so they add no runtime overhead. Java generics instead use type erasure: type parameter information is gone at runtime.
Java's Type Erasure
// After compilation, T is erased to Object
public class Box<T> {
private T value;
public T getValue() { return value; }
}
// At runtime, Box<String> and Box<Integer> are the same Box class
Box<String> stringBox = new Box<>();
Box<Integer> intBox = new Box<>();
System.out.println(stringBox.getClass() == intBox.getClass()); // trueType erasure was a design decision made for backward compatibility when generics were added in Java 5. Generics could be introduced without breaking existing bytecode, at the cost of type information being unavailable at runtime.
Null Safety
Null references are a major source of bugs in many languages. Tony Hoare called the invention of the null reference his "billion-dollar mistake." Languages deal with it in different ways.
Rust: Option<T> — Absence Expressed in the Type
// Rust has no null — use Option<T>
fn find_user(id: u64) -> Option<String> {
if id == 1 {
Some("Alice".to_string())
} else {
None
}
}
// Pattern matching safely extracts the value
match find_user(1) {
Some(name) => println!("Found: {}", name),
None => println!("Not found"),
}Rust's safe references cannot be null. A value that may be absent is expressed with the Option<T> type. Option is an enum that is either Some(T) or None, and to use the value inside you must extract it explicitly, for example with pattern matching, if let, or the ? operator. Raw pointers and FFI can still represent null, but using them safely requires explicit unsafe boundaries or checks.
TypeScript: strictNullChecks — Null as Part of the Type
// With strictNullChecks: true
function getLength(s: string | null): number {
// return s.length; // Error: 's' is possibly 'null'
if (s !== null) {
return s.length; // OK — after null check, treated as string
}
return 0;
}With the strictNullChecks compiler option enabled, TypeScript treats null and undefined explicitly as members of union types. Control flow analysis narrows types after conditional branches (type narrowing).
Go: nil — No Nil Checking at Compile Time
// Go doesn't track nil at compile time
func findUser(id int) *User {
if id == 1 {
return &User{Name: "Alice"}
}
return nil // nil might be returned
}
// Forgetting nil check causes runtime panic
user := findUser(2)
// fmt.Println(user.Name) // panic: nil pointer dereference
if user != nil {
fmt.Println(user.Name) // safe
}Go has nil for pointer types, and forgetting a nil check causes a runtime panic. Go's types don't distinguish values that can be nil from those that can't, and the compiler doesn't track which values might be nil, so missing checks only show up at runtime. Go accepts this trade-off in favor of simplicity.
Type System Comparison
Summary
- Static typing checks types at compile time; dynamic typing checks at runtime; gradual typing mixes both incrementally
- Structural typing determines compatibility by shape; nominal typing by declared names
- Type inference capabilities vary widely: Rust and TypeScript infer a lot, while Go intentionally limits inference for readability
- Generics parameterize types, and their design differs by language: Rust implements them by monomorphization and Java by type erasure, while Go expresses type constraints as interfaces
- On null safety, Rust's
Option<T>is especially strict for safe references, TypeScript treats null as part of the type whenstrictNullChecksis on, and Go does not check nil at compile time
Every language's type system is a set of trade-offs its designers chose to fit the projects they had in mind, and there is no "ultimate type system." If you know what your language's type system guarantees and what it doesn't, you know where to add tests and checks.
References
- Benjamin C. Pierce, Types and Programming Languages, MIT Press, 2002
- TypeScript Handbook — Type Inference
- The Rust Programming Language — Generic Types, Traits, and Lifetimes
- Go Blog — An Introduction To Generics
- PEP 484 — Type Hints
- Tony Hoare, Null References: The Billion Dollar Mistake, QCon London, 2009