How to Implement Traits for Polymorphism in Rust 1.86, TypeScript 5.7, and Go 1.24
Polymorphism is a core object-oriented principle that allows objects of different types to be treated as objects of a common type. Traits (or interfaces, protocols) are the primary mechanism to achieve this in many modern languages. This guide walks through implementing trait-based polymorphism in Rust 1.86, TypeScript 5.7, and Go 1.24, with practical code examples and key differences between each approach.
Prerequisites
- Basic knowledge of Rust, TypeScript, and Go syntax
- Rust 1.86 installed (verify with
rustc --version) - TypeScript 5.7 installed (verify with
tsc --version) - Go 1.24 installed (verify with
go version)
What Are Traits?
Traits define a set of method signatures that a type must implement to conform to the trait. They enable polymorphism by letting you write code that works with any type that implements the trait, without needing to know the type's concrete details. While Rust calls these "traits", TypeScript uses "interfaces" and Go uses "interfaces" as well — all serve the same core purpose for polymorphism.
Implementing Traits in Rust 1.86
Rust's trait system supports both static (compile-time) and dynamic (runtime) polymorphism. Static dispatch uses generics with trait bounds, while dynamic dispatch uses trait objects (dyn Trait).
Step 1: Define a Trait
Define a trait with the trait keyword, listing required method signatures:
trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
Step 2: Implement the Trait for Types
Use the impl Trait for Type syntax to implement the trait for concrete types:
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn perimeter(&self) -> f64 {
2.0 * std::f64::consts::PI * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}
Step 3: Use Traits for Polymorphism
For static dispatch (compile-time, no runtime overhead), use generics with trait bounds:
fn print_shape_info(shape: &T) {
println!("Area: {}", shape.area());
println!("Perimeter: {}", shape.perimeter());
}
For dynamic dispatch (runtime, flexible type erasure), use trait objects:
fn print_dynamic_shape_info(shapes: &[&dyn Shape]) {
for shape in shapes {
println!("Area: {}", shape.area());
println!("Perimeter: {}", shape.perimeter());
}
}
Rust 1.86 includes stabilized features like impl Trait in more positions, but the core trait system remains consistent with prior versions.
Implementing Traits in TypeScript 5.7
TypeScript uses interfaces to define trait-like contracts. Since TypeScript compiles to JavaScript (which has no native interface concept), interfaces are erased at runtime — polymorphism is enforced at compile time, with runtime checks possible via type guards.
Step 1: Define an Interface
Define an interface with the interface keyword, listing method signatures:
interface Shape {
area(): number;
perimeter(): number;
}
Step 2: Implement the Interface
TypeScript uses structural typing (duck typing) — any type that matches the interface's shape automatically implements it, no explicit implements keyword required (though implements is recommended for clarity):
class Circle implements Shape {
constructor(public radius: number) {}
area(): number {
return Math.PI * this.radius * this.radius;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class Rectangle implements Shape {
constructor(public width: number, public height: number) {}
area(): number {
return this.width * this.height;
}
perimeter(): number {
return 2 * (this.width + this.height);
}
}
Step 3: Use Interfaces for Polymorphism
Write functions that accept the interface type, working with any conforming object:
function printShapeInfo(shape: Shape): void {
console.log(`Area: ${shape.area()}`);
console.log(`Perimeter: ${shape.perimeter()}`);
}
// Works with any Shape-conforming object
const circle = new Circle(5);
const rect = new Rectangle(4, 6);
printShapeInfo(circle);
printShapeInfo(rect);
TypeScript 5.7 adds improved type inference for interfaces and better support for implements checks on complex types.
Implementing Traits in Go 1.24
Go's interface system is implicitly implemented (structural typing, like TypeScript) — no explicit declaration of intent to implement an interface is needed. Interfaces are satisfied automatically if a type has all methods defined in the interface.
Step 1: Define an Interface
Define an interface with the type ... interface syntax, listing method signatures:
type Shape interface {
Area() float64
Perimeter() float64
}
Step 2: Implement the Interface
Define types with methods matching the interface — Go will automatically consider them as implementing the interface:
type Circle struct {
Radius float64
}
type Rectangle struct {
Width float64
Height float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.Radius
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
Note: You must import the math package at the top of your Go file for math.Pi.
Step 3: Use Interfaces for Polymorphism
Write functions that accept the interface type, working with any conforming type:
func printShapeInfo(s Shape) {
fmt.Printf("Area: %v\n", s.Area())
fmt.Printf("Perimeter: %v\n", s.Perimeter())
}
func main() {
circle := Circle{Radius: 5}
rect := Rectangle{Width: 4, Height: 6}
printShapeInfo(circle)
printShapeInfo(rect)
}
Go 1.24 includes minor improvements to interface runtime checks and better error messages for unimplemented interface methods.
Key Differences Between Implementations
- Rust: Traits require explicit implementation, support both static and dynamic dispatch, no runtime overhead for static dispatch.
- TypeScript: Interfaces are structurally typed, erased at runtime, compile-time only checks (unless using type guards).
- Go: Interfaces are structurally typed, implicitly implemented, runtime polymorphism via interface values.
Best Practices
- Prefer static dispatch (generics) in Rust when possible for performance.
- Use explicit
implementsin TypeScript to avoid accidental interface mismatches. - Keep Go interfaces small (single-method interfaces are common) for flexibility.
- Test trait/interface implementations thoroughly to catch missing methods early.
Conclusion
All three languages support trait-based polymorphism, but with different tradeoffs: Rust offers fine-grained control over dispatch, TypeScript prioritizes developer ergonomics with structural typing, and Go provides simple, implicit interface implementation. Choose the approach that fits your project's needs and language constraints.
Top comments (0)