Module 1 · Lesson 2 · ~25 min read
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.
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."
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.
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.
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.
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.
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.
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."
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?"
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.
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.
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).
type NewName UnderlyingType — every named type is distinct for type checking.