All posts

A Deep Dive into Type Systems — What Static Typing Really Protects You From

From the basics of type systems to static vs dynamic typing, structural vs nominal typing, type inference, and generics — explained with interactive diagrams.

Type SystemTypeScriptGoRustInteractive

When choosing a programming language, the type system is one of the most important characteristics. Yet the simple classification of "static" vs "dynamic" typing barely scratches the surface. This article explores type system design from multiple angles, building understanding through comparisons of TypeScript, Go, Rust, Java, and Python.

What Is a Type System?

A type system is a set of rules that classifies values in a program and prevents invalid operations. For example, "dividing a string" or "calling a method that doesn't exist" — type systems catch these bugs 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.

In other words, a type system is a practical syntactic method that proves the absence of certain program behaviors by classifying expressions according to the kinds of values they produce.

Static Typing vs Dynamic Typing

Type System ClassificationType SystemsStatic TypingChecked at compile timeJava, Go, Rust, C++Dynamic TypingChecked at runtimePython, Ruby, JSGradual TypingMix of static & dynamicTypeScript, Python+mypy

Static Typing

Types are checked at compile time (before execution). Type errors are caught 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)
}

Benefits:

  • Early bug detection (CI catches type errors at compile time)
  • Powerful IDE completion and refactoring support
  • Type annotations serve as documentation

Representative languages: Go, Rust, Java, C++, Haskell

Dynamic Typing

Types are checked at runtime. Variables themselves 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 runtime

Benefits:

  • Concise code and fast prototyping
  • Flexible data manipulation (duck typing)
  • Lower learning curve

Representative languages: Python, Ruby, JavaScript, PHP

Gradual Typing

An approach that aims to combine the best of both static and dynamic typing. Type annotations can be added incrementally, 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, allowing gradual introduction of types into existing JavaScript codebases. Python also introduced type hints via PEP 484, enabling static checking through external tools like mypy.

Structural Typing vs Nominal Typing

Even among statically typed languages, there are two fundamentally different approaches to how type compatibility is determined.

Structural vs Nominal TypingStructural TypingTypeScript, Go (interface)Dogname: stringage: numberCatname: stringage: number✓ Compatible (same shape)Dog = Cat is allowed// Both have {name, age}let x: Dog = cat; // OK ✓Nominal TypingJava, C#, Rust, KotlinDogname: Stringage: intCatname: Stringage: int✗ Incompatible (different names)Dog ≠ Cat despite same fields// Same fields, different typesDog x = cat; // Error ✗

Structural Typing

Type compatibility is determined by structure (shape). Regardless of type names, if a value has the required properties or methods, it's considered 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 matches

Go 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 types

Rust also uses nominal typing. Even structs with identical fields require 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 types

This pattern in Rust (the newtype pattern) is extremely useful for distinguishing semantically different values — distances, currencies, IDs — at the type level.

Which Is Better?

Neither is universally superior. Each has appropriate use cases:

AspectStructural TypingNominal Typing
FlexibilityHigh (retroactive compatibility)Lower (explicit declarations required)
SafetySomewhat lower (unintended compatibility)Higher (explicit type distinction)
Best forAPI boundaries, external dataDomain modeling, type safety

Type Inference

Type inference is the ability for the compiler to automatically deduce types from expressions without explicit annotations.

Type Inference Step by Step

Watch how TypeScript infers types from expressions without explicit annotations.

let x = 42;
Inferredx: number

Infers x as number from literal 42

Type Environment
x: number
1 / 7

Type Inference Capabilities by Language

The "strength" of type inference varies greatly across languages.

TypeScript — Powerful inference:

// 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 } inferred

Rust — Powerful Hindley-Milner based inference:

// 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 based on the Hindley-Milner type inference algorithm and performs very powerful inference for local variables. However, function signatures require explicit type annotations. This is a design decision to ensure readability as documentation and to guarantee inference decidability.

Go — Limited inference (:=):

// 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 intentionally keeps type inference limited. As Rob Pike has repeatedly stated, this is a design choice prioritizing readability.

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 | undefined

Go 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, are notable for expressing type constraints through interfaces. The tilde syntax ~int accepts all types whose underlying type is int.

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 concrete types at compile time through monomorphization, resulting in zero runtime overhead. In contrast, Java generics use type erasure, where type parameter information is lost 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()); // true

Type erasure was a design decision for backward compatibility when generics were added in Java 5. It allowed introducing generics while maintaining compatibility with existing bytecode, but at the cost of not being able to access type information at runtime.

Null Safety

Null references are a major source of bugs in many languages. Tony Hoare called the invention of null his "billion-dollar mistake." Languages take different approaches to null safety.

Rust: Complete Null Elimination with Option<T>

// 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 has no null at the language level. The possibility of absent values is built into the type system as Option<T>. Option is an enum with either Some(T) or None, and you must safely unwrap it using pattern matching, if let, or the ? operator.

TypeScript: strictNullChecks

// 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;
}

TypeScript's strictNullChecks compiler option treats null and undefined explicitly as union types. Through control flow analysis, types are narrowed after conditional branches (type narrowing).

Go: nil — No Compile-Time Nil Safety

// 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. While nil itself is typed, the compiler doesn't track nil-ability at compile time, so missing checks are only caught at runtime. This is a design trade-off in Go that prioritizes simplicity.

Type System Comparison

Type System ComparisonLanguageTypingStruct?InferenceGenericsNull SafetyTypeScriptStatic (Gradual)Strongstrict modeGoStaticLimited (:=)✓ (1.18+)nil (no)RustStaticStrongOption<T>JavaStaticLimited (var)✓ (erased)Optional (partial)PythonDynamic (+ hints)✓ (hints)Optional (hints)

Summary

  • A type system is a set of rules for classifying values and preventing invalid operations
  • 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 have powerful inference, while Go intentionally limits it for readability
  • Generics parameterize types — Rust uses monomorphization, Java uses type erasure, Go uses type constraints, each with different trade-offs
  • Null safety is a critical type system concern — Rust's Option<T> is the most rigorous approach, while TypeScript's strictNullChecks and Go's nil make different trade-offs

Type systems are an accumulation of language designers' trade-offs. There is no "ultimate type system" — each language makes different design decisions suited to different project needs. What matters is understanding what your language's type system guarantees and what it doesn't.

References