Module 1 · Lesson 2 · ~25 min read

Types, Structs, Methods — Not Classes

Go has types, structs, and methods. It does not have classes. That distinction isn't pedantic — if you try to use Go as if it had classes, you'll fight the language constantly. The way out is understanding that Go's unit of code organization is the package, and its unit of data is the struct.

Named types

Any type can be given a name. A named type is a distinct type at the compiler level, even if its underlying representation is the same.

type UserID string
type OrderID string

var u UserID = "alice"
var o OrderID = u  // COMPILE ERROR: cannot use UserID as OrderID
var o2 OrderID = OrderID(u)  // OK — explicit conversion

This is how Go gets newtype-style safety. In Canton's codebase (once you have the Go ecosystem around it), you'll see type PartyID string, type TemplateID string, type CommandID string — all strings underneath, all distinct at the type level. A function that expects a PartyID will not silently accept a raw string.

Note the syntax: type NewName ExistingType. It reads left-to-right: "let NewName be a distinct type based on ExistingType."

Structs

A struct is a named record. Fields are ordered and have types.

type Participant struct {
    ID      string
    Version int
    Parties []string
}

// Three ways to construct one:

p1 := Participant{"alice", 5, []string{"Alice"}}   // positional — fragile, avoid for 2+ fields

p2 := Participant{
    ID:      "alice",
    Version: 5,
    Parties: []string{"Alice"},
}                                                   // named — idiomatic

var p3 Participant                                 // zero value — p3.ID == "", p3.Parties == nil

Always use named struct literals for anything with more than one field. Positional literals break silently when someone reorders fields.

Field tags

Fields can carry tags — backtick-delimited metadata strings used by reflection-driven libraries.

type User struct {
    ID    string `json:"id" db:"user_id"`
    Email string `json:"email,omitempty" validate:"required,email"`
}

Tags don't do anything on their own. They're read at runtime by packages like encoding/json, database/sql, and validation libraries. In Canton Go work, you'll see protobuf-generated tags for wire field numbers, JSON tags for API shapes, and validation tags.

Methods

A method is a function with a receiver. The receiver is the type the method is attached to, declared before the function name.

type Duration int64  // nanoseconds

func (d Duration) Seconds() float64 {
    return float64(d) / 1e9
}

var d Duration = 2500000000
fmt.Println(d.Seconds())  // 2.5

Notice: methods can be attached to any named type, not just structs. Duration here is just a named int64, but it has a method. This is how Go does lightweight "primitive wrappers" that JS or Python would do with classes.

Rule

You can only define methods on named types declared in the same package as the method. You cannot add methods to int, string, or types from other packages. Workaround: define a local named type — type MyString string — and put methods on that.

Value receiver vs pointer receiver

type Counter struct { n int }

func (c Counter) Read() int { return c.n }       // value receiver: c is a copy
func (c *Counter) Inc()         { c.n++ }          // pointer receiver: c points at the real thing

Value receiver = a copy of the struct is passed to the method. Mutations don't stick. Pointer receiver = a pointer is passed; mutations stick. Lesson 3 goes deep on when to use which. For now, the practical rule: if the method mutates the receiver or the receiver is large, use a pointer receiver. Otherwise, either works — pick one and be consistent within a type.

No classes, no inheritance — composition via embedding

In Python:

class Animal:
    def breathe(self): ...

class Dog(Animal):
    def bark(self): ...

In Go:

type Animal struct { name string }
func (a *Animal) Breathe() { ... }

type Dog struct {
    *Animal            // embedded field — no name
    Breed string
}
func (d *Dog) Bark() { ... }

d := &Dog{Animal: &Animal{name: "Rex"}, Breed: "collie"}
d.Breathe()  // works — promoted from embedded Animal
d.Bark()
d.name      // also works — embedded fields are promoted

Embedding looks like inheritance but behaves like composition. There's no vtable, no polymorphic dispatch based on the outer type, no "overriding." It's sugar for "delegate everything to this field by default, but I can shadow any method I want by defining one on the outer type."

Antipattern

Don't try to recreate an inheritance hierarchy with embedding. Go idiom is flat: define behaviors as interfaces, implement them on concrete types, compose structs when needed. If you find yourself with Dog-embeds-Mammal-embeds-Animal, stop and ask: "what interface am I actually trying to express?"

Methods as first-class values

You can bind a method to a specific receiver and pass it around:

c := &Counter{}
increment := c.Inc  // method value — closes over c
increment()           // mutates the original c
increment()
fmt.Println(c.Read())  // 2

Handy for callbacks and HTTP handlers. You'll see this pattern in Canton integrations that register callback functions against event streams.

Constants

Briefly, because they'll come up. Constants are compile-time values:

const (
    DefaultTimeout = 30 * time.Second  // untyped, becomes Duration in context
    MaxRetries     int = 5
)

Go has untyped constants — a literal 42 can be assigned to any integer-compatible type without conversion. Only explicit typed constants lock to a single type. This lets numeric literals just work across the number types without the conversion noise of, say, Rust.

iota for enum-like sequences

type ContractStatus int

const (
    StatusPending ContractStatus = iota  // 0
    StatusActive                         // 1
    StatusArchived                       // 2
)

Go's closest thing to an enum. No variants carrying different payloads like Rust or Scala — for that, you use interfaces and type switches (next lesson).

Takeaways