Overview
Type systems determine when and how programming languages verify that operations use compatible data types. Static typing performs type checking before program execution, typically during compilation. Dynamic typing defers type checking until runtime, verifying types as the program executes.
The distinction affects error detection, development speed, refactoring safety, and runtime performance. Statically typed languages reject programs with type errors before execution. Dynamically typed languages allow type errors to surface during execution, requiring different testing and debugging strategies.
Ruby uses dynamic typing, checking types when code executes rather than when the interpreter loads the program. This choice influences Ruby's syntax, metaprogramming capabilities, and development patterns.
# Ruby performs type checking at runtime
def add(a, b)
a + b
end
add(5, 3) # => 8
add("Hello", " World") # => "Hello World"
add(5, "text") # TypeError at runtime, not load time
The type system affects API design, testing requirements, documentation practices, and debugging workflows. Languages with static typing require explicit type declarations or type inference. Languages with dynamic typing determine types from runtime values.
Key Principles
Static type systems assign types to variables, parameters, and return values before execution. The compiler or interpreter analyzes code to verify type compatibility, rejecting programs that perform invalid operations. Type errors become compilation failures rather than runtime exceptions.
Type checking occurs through explicit annotations or type inference. Explicit typing requires developers to declare types for variables and function signatures. Type inference deduces types from context, reducing annotation overhead while maintaining compile-time verification.
// Static typing with explicit annotations (Java)
String name = "Alice";
int age = 30;
// name = 42; // Compilation error: incompatible types
Dynamic type systems defer type verification until code executes. Variables hold references to values without declaring the expected type. Type checks occur when operations execute, raising runtime errors if types prove incompatible.
# Dynamic typing (Ruby)
name = "Alice"
age = 30
name = 42 # Valid: variables accept any type
Type safety describes how strictly a language prevents type errors. Strongly typed languages prevent implicit conversions between incompatible types. Weakly typed languages perform automatic coercions, sometimes producing unexpected results.
# Ruby is strongly typed despite dynamic typing
5 + "10" # TypeError: String can't be coerced into Integer
# JavaScript weakly typed
5 + "10" # "510" - automatic string conversion
Static typing and strong typing represent independent dimensions. A language can be statically typed and weakly typed (C allows many implicit conversions) or dynamically typed and strongly typed (Ruby prevents most implicit conversions).
Type inference bridges explicit typing and developer convenience. Modern statically typed languages deduce types from initialization values, function bodies, and usage patterns. This reduces annotation burden while maintaining compile-time safety.
// TypeScript type inference
let count = 42; // Inferred as number
// count = "text"; // Error: Type 'string' not assignable to type 'number'
function double(x: number) {
return x * 2; // Return type inferred as number
}
Structural typing determines compatibility based on shape rather than explicit declarations. If an object provides the required methods and properties, it satisfies the type regardless of nominal type declarations.
Nominal typing requires explicit type relationships. Types match only when declared to have a relationship, even if structurally identical.
# Ruby uses duck typing (structural at runtime)
def log_message(logger)
logger.write("Message") # Works if logger has write method
end
class FileLogger
def write(text)
File.write("log.txt", text)
end
end
class ConsoleLogger
def write(text)
puts text
end
end
log_message(FileLogger.new) # Works
log_message(ConsoleLogger.new) # Works - same interface
Type variance governs how type relationships apply to generic types. Covariance allows substituting subtypes for supertypes. Contravariance allows substituting supertypes for subtypes. Invariance requires exact type matches.
Ruby Implementation
Ruby implements dynamic typing through runtime type checking and duck typing. The interpreter verifies type compatibility when executing operations, not when loading code. This allows flexible variable assignment and method polymorphism without explicit type declarations.
# Variables accept any type
value = 42
value = "text"
value = [1, 2, 3]
value = { key: "value" }
Type checking occurs in method implementations through explicit checks or implicit operations. Methods perform type-specific operations that fail with descriptive errors for incompatible types.
class Calculator
def multiply(a, b)
a * b # Type check happens here
end
end
calc = Calculator.new
calc.multiply(5, 3) # => 15
calc.multiply("x", 3) # => "xxx"
calc.multiply(5, "y") # TypeError: String can't be coerced into Integer
Duck typing determines type compatibility from available methods rather than class hierarchy. If an object responds to the required messages, it satisfies the interface regardless of class.
class TextReader
def read_from(source)
content = source.read
content.upcase
end
end
class FileSource
def read
File.read("data.txt")
end
end
class StringSource
def initialize(text)
@text = text
end
def read
@text
end
end
reader = TextReader.new
reader.read_from(FileSource.new)
reader.read_from(StringSource.new("hello")) # Both work
The respond_to? method enables explicit interface checking, verifying method availability before invocation. This provides defensive programming in dynamic contexts.
def safe_process(obj)
if obj.respond_to?(:process)
obj.process
else
raise ArgumentError, "Object must respond to :process"
end
end
Ruby provides runtime type introspection through class, is_a?, kind_of?, and instance_of?. These methods enable type checking when necessary, though duck typing reduces their need.
def handle_value(value)
case value
when String
value.upcase
when Integer
value * 2
when Array
value.map(&:to_s)
else
value.to_s
end
end
The method_missing hook enables dynamic method handling, creating methods at runtime based on incoming messages. This metaprogramming capability depends on dynamic typing.
class DynamicAccessor
def initialize(data)
@data = data
end
def method_missing(name, *args)
@data[name] || super
end
def respond_to_missing?(name, include_private = false)
@data.key?(name) || super
end
end
obj = DynamicAccessor.new({ name: "Alice", age: 30 })
obj.name # => "Alice"
obj.age # => 30
Ruby 3 introduced RBS (Ruby Signature) files for optional static type checking. RBS files describe method signatures separately from implementation, enabling static analysis without modifying Ruby code.
# person.rb
class Person
attr_accessor :name, :age
def initialize(name, age)
@name = name
@age = age
end
def greet
"Hello, I'm #{@name}"
end
end
# person.rbs
class Person
attr_accessor name: String
attr_accessor age: Integer
def initialize: (String name, Integer age) -> void
def greet: () -> String
end
Sorbet provides gradual typing for Ruby through annotations in comments or separate interface files. This enables static type checking while maintaining Ruby's dynamic runtime behavior.
# typed: true
extend T::Sig
sig { params(name: String, age: Integer).returns(String) }
def format_person(name, age)
"#{name} is #{age} years old"
end
Design Considerations
Choosing between static and dynamic typing affects development velocity, bug detection, refactoring safety, and runtime performance. Static typing catches type errors before execution, preventing entire classes of runtime failures. Dynamic typing reduces ceremony and enables rapid prototyping without type annotations.
Static typing provides compiler-verified documentation. Function signatures communicate expected types, making APIs self-documenting. IDEs leverage type information for autocomplete, navigation, and refactoring support.
// Static typing documents expectations
function calculateDiscount(
price: number,
discountPercent: number
): number {
return price * (1 - discountPercent / 100);
}
Dynamic typing requires external documentation or type hints. Developers must read implementation code or documentation to understand expected types. Runtime errors surface type mismatches during testing or production.
# Dynamic typing requires documentation
def calculate_discount(price, discount_percent)
# @param price [Float] the original price
# @param discount_percent [Float] the discount percentage
# @return [Float] the discounted price
price * (1 - discount_percent / 100.0)
end
Refactoring safety differs dramatically. Static typing enables aggressive automated refactoring. Renaming methods, changing signatures, or restructuring code triggers compiler errors at every affected call site. Dynamic typing requires comprehensive test coverage to catch refactoring breaks.
Code flexibility varies inversely with type constraints. Dynamic typing enables polymorphism through duck typing, accepting any object with compatible methods. Static typing requires explicit interface declarations or generics, adding verbosity for the same flexibility.
# Dynamic typing: any enumerable works
def sum_values(collection)
collection.reduce(0, :+)
end
sum_values([1, 2, 3])
sum_values(Set.new([1, 2, 3]))
sum_values(1..10)
// Static typing: requires generic constraints
function sumValues<T extends Iterable<number>>(collection: T): number {
let sum = 0;
for (const value of collection) {
sum += value;
}
return sum;
}
Development speed varies by project phase. Dynamic typing accelerates initial development and exploration, allowing rapid iteration without type declarations. Static typing slows initial development but accelerates maintenance through compiler-verified changes.
Runtime performance differences emerge from type checking overhead and optimization opportunities. Static typing enables aggressive compiler optimizations based on type guarantees. Dynamic typing incurs runtime type checks and prevents certain optimizations.
Metaprogramming capabilities depend heavily on type system flexibility. Dynamic typing enables runtime code generation, method synthesis, and structural modification. Static typing restricts metaprogramming to compile-time transformations.
# Dynamic metaprogramming
class DynamicModel
def self.define_accessors(attrs)
attrs.each do |attr|
define_method(attr) { instance_variable_get("@#{attr}") }
define_method("#{attr}=") { |val| instance_variable_set("@#{attr}", val) }
end
end
end
class User < DynamicModel
define_accessors [:name, :email, :age]
end
Error detection timing represents the fundamental trade-off. Static typing reports all type errors before execution, but requires upfront type specifications. Dynamic typing discovers errors during execution, but requires less upfront investment.
Team dynamics influence type system selection. Large teams benefit from static typing's explicit contracts and compiler enforcement. Small teams may prefer dynamic typing's flexibility and reduced ceremony. Experience level affects the trade-off; beginners benefit from static typing's immediate feedback.
Practical Examples
Type errors manifest differently in static and dynamic systems. Static systems reject programs with type errors during compilation. Dynamic systems execute until reaching the type error.
# Dynamic typing: error occurs at runtime
def calculate_total(items)
items.map { |item| item[:price] }.sum
end
# This loads without error
calculate_total([{ price: 10 }, { price: 20 }]) # => 30
calculate_total([{ price: 10 }, "invalid"]) # TypeError at runtime
// Static typing: error occurs at compile time
public double calculateTotal(List<Item> items) {
return items.stream()
.mapToDouble(item -> item.getPrice())
.sum();
}
// calculateTotal(List.of("invalid")); // Compilation error
Protocol implementation through duck typing demonstrates dynamic typing flexibility. Multiple unrelated classes satisfy the same protocol without explicit declarations.
# Different classes implementing the same protocol
class JSONSerializer
def serialize(obj)
obj.to_json
end
end
class XMLSerializer
def serialize(obj)
obj.to_xml
end
end
class YAMLSerializer
def serialize(obj)
obj.to_yaml
end
end
def save_data(data, serializer)
File.write("output", serializer.serialize(data))
end
# All serializers work without shared parent class
save_data({ name: "Alice" }, JSONSerializer.new)
save_data({ name: "Bob" }, XMLSerializer.new)
Gradual typing combines static and dynamic approaches. Type annotations provide static checking where valuable, while leaving other code dynamically typed.
# typed: true
class OrderProcessor
extend T::Sig
# Statically typed method
sig { params(order: Order, processor: PaymentProcessor).returns(T::Boolean) }
def process_payment(order, processor)
processor.charge(order.total)
end
# Dynamically typed method
def log_order(order)
# No type checking here
puts "Processing order: #{order.inspect}"
end
end
Runtime type checking handles dynamic data with unknown structure. Web API responses, user input, and external data require validation regardless of type system.
class UserValidator
def self.valid?(data)
return false unless data.is_a?(Hash)
return false unless data[:email].is_a?(String)
return false unless data[:age].is_a?(Integer)
return false unless data[:age] >= 0
true
end
end
def create_user(data)
raise ArgumentError, "Invalid user data" unless UserValidator.valid?(data)
User.new(data[:email], data[:age])
end
Type coercion strategies differ between systems. Static typing generally prevents implicit conversions. Dynamic typing may allow coercion but strong typing limits automatic conversions.
# Explicit coercion in Ruby
def format_age(age)
"Age: #{age.to_i}" # Explicit conversion
end
# Handling multiple input types
def parse_number(value)
case value
when Integer
value
when String
Integer(value) # Raises ArgumentError if invalid
when Float
value.to_i
else
raise TypeError, "Cannot convert #{value.class} to number"
end
end
Generic programming approaches vary by type system. Static typing uses parameterized types. Dynamic typing relies on duck typing without explicit generics.
# Dynamic generic-like behavior through duck typing
class Container
def initialize
@items = []
end
def add(item)
@items << item
end
def each(&block)
@items.each(&block)
end
def map(&block)
@items.map(&block)
end
end
numbers = Container.new
numbers.add(1)
numbers.add(2)
numbers.map { |n| n * 2 } # => [2, 4]
strings = Container.new
strings.add("hello")
strings.add("world")
strings.map(&:upcase) # => ["HELLO", "WORLD"]
Common Patterns
Defensive type checking validates inputs explicitly when type safety matters. This pattern adds runtime checks in dynamically typed languages or at system boundaries in statically typed languages.
def divide(numerator, denominator)
raise TypeError, "numerator must be Numeric" unless numerator.is_a?(Numeric)
raise TypeError, "denominator must be Numeric" unless denominator.is_a?(Numeric)
raise ArgumentError, "denominator cannot be zero" if denominator.zero?
numerator / denominator.to_f
end
Type guards narrow types within conditional branches, informing type checkers about refined types in specific code paths.
def process_value(value)
if value.is_a?(String)
# value is known to be String here
value.upcase
elsif value.is_a?(Numeric)
# value is known to be Numeric here
value * 2
elsif value.respond_to?(:to_s)
# value has to_s method
value.to_s
else
raise TypeError, "Unsupported type: #{value.class}"
end
end
Protocol-based design defines expected interfaces through documentation or explicit checks rather than class hierarchies. Objects satisfy protocols by implementing required methods.
module Comparable
# Objects implementing this protocol must define:
# - <=>(other): returns -1, 0, or 1
def <(other)
(self <=> other) < 0
end
def >(other)
(self <=> other) > 0
end
def ==(other)
(self <=> other) == 0
end
end
class Temperature
include Comparable
attr_reader :celsius
def initialize(celsius)
@celsius = celsius
end
def <=>(other)
@celsius <=> other.celsius
end
end
Factory methods with runtime type selection instantiate different classes based on input data, leveraging dynamic typing.
class ReportFactory
def self.create(data)
case data[:type]
when :pdf
PDFReport.new(data)
when :html
HTMLReport.new(data)
when :csv
CSVReport.new(data)
else
raise ArgumentError, "Unknown report type: #{data[:type]}"
end
end
end
report = ReportFactory.create({ type: :pdf, title: "Sales Report" })
Adapter pattern converts interfaces to match expected protocols, compensating for interface mismatches in both type systems.
class LegacyPrinter
def print_document(doc)
puts "Printing: #{doc}"
end
end
class PrinterAdapter
def initialize(legacy_printer)
@printer = legacy_printer
end
def print(content)
@printer.print_document(content)
end
end
# Adapts old interface to new protocol
def print_all(items, printer)
items.each { |item| printer.print(item) }
end
printer = PrinterAdapter.new(LegacyPrinter.new)
print_all(["Doc1", "Doc2"], printer)
Null object pattern provides type-compatible objects that perform no-op operations, eliminating nil checks.
class NullLogger
def debug(msg); end
def info(msg); end
def warn(msg); end
def error(msg); end
end
class ServiceWithLogging
def initialize(logger = NullLogger.new)
@logger = logger
end
def perform_action
@logger.info("Starting action")
# Action logic
@logger.info("Completed action")
end
end
# Works with real logger or null logger
service = ServiceWithLogging.new(RealLogger.new)
service_without_logging = ServiceWithLogging.new
Type conversion protocols standardize object conversion through conventional methods like to_i, to_s, to_a, providing predictable coercion.
class Temperature
def initialize(celsius)
@celsius = celsius
end
def to_i
@celsius.to_i
end
def to_f
@celsius.to_f
end
def to_s
"#{@celsius}°C"
end
end
temp = Temperature.new(23.5)
puts "Temperature: #{temp}" # Uses to_s
number = temp.to_i # Explicit conversion
Reference
Type System Comparison
| Characteristic | Static Typing | Dynamic Typing |
|---|---|---|
| Type Check Timing | Compile-time | Runtime |
| Type Declarations | Required or inferred | Not required |
| Error Detection | Before execution | During execution |
| Refactoring Support | High - compiler verifies | Lower - requires tests |
| Development Speed | Slower initially | Faster initially |
| Runtime Performance | Generally faster | Generally slower |
| Metaprogramming | Limited to compile-time | Extensive runtime support |
| Documentation | Types self-document | Requires external docs |
| IDE Support | Advanced autocompletion | Basic completion |
Ruby Type Checking Methods
| Method | Purpose | Returns |
|---|---|---|
| is_a? | Check if object is instance of class or subclass | Boolean |
| kind_of? | Alias for is_a? | Boolean |
| instance_of? | Check exact class match | Boolean |
| respond_to? | Check if object has method | Boolean |
| class | Get object's class | Class object |
| === | Case equality - used in case statements | Boolean |
Type Strength Characteristics
| Aspect | Strongly Typed | Weakly Typed |
|---|---|---|
| Implicit Conversion | Minimal or none | Extensive |
| Type Errors | Explicit failures | May silently convert |
| Type Safety | High | Variable |
| Examples | Ruby, Python | JavaScript, PHP |
Common Type Patterns in Ruby
| Pattern | Implementation | Use Case |
|---|---|---|
| Duck Typing | Check respond_to? instead of class | Flexible interfaces |
| Type Guard | Use is_a? in conditionals | Type-specific logic |
| Conversion Protocol | Define to_i, to_s, to_a, etc. | Standard conversions |
| Null Object | Create no-op class matching interface | Eliminate nil checks |
| Factory Method | Select class based on runtime data | Dynamic instantiation |
Gradual Typing Tools
| Tool | Approach | Integration |
|---|---|---|
| RBS | Separate signature files | External type definitions |
| Sorbet | Inline or separate annotations | Comment-based or external |
| Steep | Type checking with RBS | Uses RBS files |
| TypeProf | Type inference generator | Generates RBS from code |
Type Validation Example
class TypeValidator
def self.validate_hash(data, schema)
schema.each do |key, expected_type|
value = data[key]
unless value.is_a?(expected_type)
raise TypeError,
"Expected #{key} to be #{expected_type}, got #{value.class}"
end
end
true
end
end
schema = {
name: String,
age: Integer,
active: TrueClass.singleton_class
}
TypeValidator.validate_hash({ name: "Alice", age: 30, active: true }, schema)
Static vs Dynamic Testing Requirements
| Aspect | Static Typing | Dynamic Typing |
|---|---|---|
| Type Coverage | Compiler guarantees | Must test all paths |
| Test Focus | Business logic | Types + business logic |
| Refactoring Tests | Fewer needed | More comprehensive needed |
| Integration Tests | Focus on behavior | Must verify types too |
| Mock Objects | Type-safe mocks | Any object works |