Skip to main content

Types and Structs

For developers with .NET (C#) experience, this guide covers struct, type definitions, struct tags, embedding, value semantics, and how they differ from class / record.

1. The Big Picture of Go's Type System​

Go has no class. You define data with a struct and add behavior with methods (see Methods and Interfaces). There is no inheritance; reuse is achieved through composition.

2. .NET Mapping​

.NET (C#)Go
class / recordstruct
InheritanceComposition via embedding
ConstructorsNew... factory functions
enumconst + iota
Object initializer new T { X = 1 }Composite literal T{X: 1}
with expression on recordsCopy the struct and change fields
Attributes ([JsonPropertyName("x")])Struct tags (`json:"x"`)
Type alias using X = YType alias type X = Y
readonly / initNone (use unexported fields + a constructor)
Equals / ==Comparable structs support ==
partial classNone (but methods can be spread across files in one package)

3. Defining and Initializing Structs​

Definition​

type User struct {
ID int
Name string
Email string
Tags []string
}

Initialization​

// 1. With field names (recommended)
u1 := User{ID: 1, Name: "Alice", Email: "[email protected]"}

// 2. Positional (all fields required; breaks when fields are added, so discouraged)
u2 := User{2, "Bob", "[email protected]", nil}

// 3. Zero value
var u3 User // ID=0, Name="", Email="", Tags=nil

// 4. Pointers
u4 := &User{ID: 4, Name: "Dave"}
u5 := new(User) // a *User holding the zero value
  • Fields you omit get their zero values (never null)
  • A trailing comma is required after the last field when written over multiple lines
  • Fields are accessed with u.Name. The same . works through a pointer (u4.Name; there is no ->)

Anonymous structs​

point := struct {
X, Y int
}{X: 1, Y: 2}

Handy for local use, such as table-driven tests or a one-off JSON request body.

Constructor functions​

Go has no constructors. By convention you define a function named New....

func NewUser(name, email string) (*User, error) {
if name == "" {
return nil, errors.New("name is required")
}
return &User{Name: name, Email: email}, nil
}
  • Create one only when you need validation or default values. For simple structs, use a composite literal directly
  • If validation can fail, return (*User, error)
  • If you design the zero value to be useful, a constructor is often unnecessary

The functional options pattern​

Since there are no optional parameters, use this for types with many settings.

type Server struct {
addr string
timeout time.Duration
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}

func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}

srv := NewServer(":8080", WithTimeout(5*time.Second))

4. Value Semantics​

In C#, a class is a reference type and a struct is a value type. A Go struct is always a value type. Assignment and function calls copy it.

a := User{Name: "Alice"}
b := a // copy
b.Name = "Bob"
fmt.Println(a.Name) // Alice (a is unchanged)

p := &a // pointer to a
p.Name = "Carol"
fmt.Println(a.Name) // Carol

Pass by value vs. by pointer​

func rename(u User) { u.Name = "changed" } // modifies a copy (original unchanged)
func renameP(u *User) { u.Name = "changed" } // modifies the original

u := User{Name: "Alice"}
rename(u)
fmt.Println(u.Name) // Alice
renameP(&u)
fmt.Println(u.Name) // changed

Rules of thumb:

SituationChoice
You need to modify the valuePointer
The struct is large (copy cost matters)Pointer
It contains fields that must not be copied, such as sync.MutexPointer
Small, immutable values (coordinates, money, timestamps)Value
When unsureUse pointer receivers consistently for methods

See Pointers and Memory Management for details.

In place of record​

The "immutability", "value equality", and with expression of C# record are achieved in Go like this.

type Point struct{ X, Y int }

p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true (== works if all fields are comparable)

p3 := p1 // copy (in place of a with expression)
p3.X = 10
  • If every field is comparable, == performs field-by-field comparison
  • Structs containing slices, maps, or functions cannot be compared with == (use reflect.DeepEqual, slices.Equal, or maps.Equal)
  • Comparable structs can also be used as map keys

5. Field Visibility and Struct Tags​

Visibility​

Fields starting with an uppercase letter are exported; lowercase ones are not.

type Account struct {
ID string // exported
balance int64 // unexported (package only)
}

See Modules and Packages for details.

Struct tags​

Tags are string literals attached to fields, playing a role similar to C# attributes. encoding/json, DB libraries, and validation libraries read them via reflection.

type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
Age int `json:"age"`
Password string `json:"-"` // excluded from JSON
Created time.Time `json:"created_at"`
}
data, _ := json.Marshal(CreateUserRequest{Name: "Alice", Age: 30})
// {"name":"Alice","age":30,"created_at":"0001-01-01T00:00:00Z"}

var req CreateUserRequest
err := json.Unmarshal(data, &req)
TagMeaning
json:"name"Sets the JSON key name
json:"name,omitempty"Omits the field when it is the zero value
json:"-"Excludes the field from input and output
db:"user_id"Column name for DB libraries (library-specific)
validate:"required"Validation library rule (library-specific)

Notes:

  • Unexported fields are not included in JSON. Leaving a field lowercase and wondering why it is missing is a classic bug
  • System.Text.Json converts casing by policy, but in Go without a tag the field name is used as-is
  • To distinguish "zero value" from "absent", use a pointer (*int), often together with omitempty

6. Type Definitions and Type Aliases​

Defining a new type​

type UserID int
type Celsius float64
type Handler func(w http.ResponseWriter, r *http.Request)
type Users []User

type UserID int creates a type distinct from int. No implicit conversion is allowed.

var id UserID = 1
var n int = int(id) // explicit conversion required
// var m int = id // compile error

This lets the compiler catch mix-ups of differently meaning IDs (such as UserID and OrderID). You can attach methods to such types too.

func (c Celsius) Fahrenheit() float64 {
return float64(c)*9/5 + 32
}

Type aliases​

type MyInt = int // another name for the same type (note the =)

An alias is merely a different name, not a new type. It is useful for gradual migration during large refactors, but rarely used otherwise.

7. Embedding (Composition Instead of Inheritance)​

Go has no inheritance. Instead, you embed a type by writing only its name inside a struct, which "promotes" its fields and methods.

type Base struct {
ID int
CreatedAt time.Time
}

func (b Base) Age() time.Duration {
return time.Since(b.CreatedAt)
}

type Article struct {
Base // embedded
Title string
}

a := Article{
Base: Base{ID: 1, CreatedAt: time.Now()},
Title: "Go",
}

fmt.Println(a.ID) // same as a.Base.ID
fmt.Println(a.Age()) // same as a.Base.Age()

Differences from inheritance​

C# inheritanceGo embedding
Relationshipis-ahas-a (plus delegation sugar)
PolymorphismAssignable to a base class variableNot possible (an Article cannot be assigned to Base)
Overriding methodsvirtual / overrideDefine a same-named method on the outer type to shadow it
Inner calling outer's methodPossible (virtual dispatch)Impossible (the inner type does not know the outer)
MultiplicitySingle inheritanceMultiple types can be embedded
type Animal struct{ Name string }

func (a Animal) Speak() string { return a.Name + " makes a sound" }
func (a Animal) Intro() string { return a.Speak() } // always Animal.Speak

type Dog struct{ Animal }

func (d Dog) Speak() string { return d.Name + " barks" }

d := Dog{Animal{"Rex"}}
fmt.Println(d.Speak()) // Rex barks
fmt.Println(d.Intro()) // Rex makes a sound (Dog.Speak is not called)

The key point: where C# would print "Rex barks" thanks to virtual, Go does not. When you need polymorphism, use an interface.

Practical usage​

type Logger struct{ *slog.Logger } // embedding a pointer is also allowed

type Service struct {
Logger
repo Repository
}

s.Info("started") // Logger's methods are promoted
  • Embedding is a mechanism to automate field and method delegation, not a way to express design-level IS-A
  • If multiple embedded types have conflicting names, ambiguous access is a compile error
  • Embedding an exported type exposes all of its exported methods in your API, so be careful

8. Enumerations (const + iota)​

type OrderStatus int

const (
OrderPending OrderStatus = iota
OrderPaid
OrderShipped
)

func (s OrderStatus) String() string {
switch s {
case OrderPending:
return "pending"
case OrderPaid:
return "paid"
case OrderShipped:
return "shipped"
default:
return fmt.Sprintf("OrderStatus(%d)", int(s))
}
}
  • Implementing String() string makes fmt.Println print a human-readable form (the Stringer interface)
  • Nothing prevents out-of-range values. Validate when converting from external input
  • To serialize as strings in JSON, use string constants or implement MarshalJSON / UnmarshalJSON
  • Prefer typed constants (like OrderStatus) over bare iota integers so the compiler catches mix-ups
// String-based enumeration (easier to serialize)
type Role string

const (
RoleAdmin Role = "admin"
RoleMember Role = "member"
)

9. Common Pitfalls for C# Developers​

  • Structs are always value types: assignment and argument passing copy them. Treating one like a class leads to changes that "don't stick"
  • Looking for inheritance: embedding is not inheritance and has no polymorphism. Use interface for polymorphism
  • No constructors: initialization cannot be enforced. Design so that the zero value is safe, or provide New... and rely on convention
  • Lowercase field names missing from JSON: you need exported fields with json tags
  • Nil slices / maps as fields: writing to a zero-value map panics. make it in the constructor
  • Copying a struct containing sync.Mutex: the lock state is copied and breaks. Use pointers (go vet's copylocks check detects this)
  • Positional initialization: breaks when fields are added. Always specify field names
  • Loop variable structs are copies: for _, u := range users { u.Name = "x" } does not modify the slice
  • No readonly or init: express immutability with unexported fields and exported methods (getters)
  • Getters do not use a Get prefix: for a field name, the getter is Name() and the setter is SetName()

10. Code Example​

C#​

public enum OrderStatus { Pending, Paid, Shipped }

public record Address(string City, string Zip);

public class Order
{
public int Id { get; init; }
public string Customer { get; set; } = "";
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public Address? ShipTo { get; set; }
public List<string> Items { get; } = new();

public Order(int id, string customer)
{
if (string.IsNullOrEmpty(customer))
throw new ArgumentException("customer is required");
Id = id;
Customer = customer;
}
}

public class AuditedOrder : Order
{
public DateTime CreatedAt { get; } = DateTime.UtcNow;
public AuditedOrder(int id, string customer) : base(id, customer) { }
}

Go​

package order

import (
"errors"
"time"
)

type Status int

const (
StatusPending Status = iota
StatusPaid
StatusShipped
)

type Address struct {
City string `json:"city"`
Zip string `json:"zip"`
}

type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Status Status `json:"status"`
ShipTo *Address `json:"ship_to,omitempty"` // omitted when nil
Items []string `json:"items"`
}

// New is a constructor function with validation
func New(id int, customer string) (*Order, error) {
if customer == "" {
return nil, errors.New("customer is required")
}
return &Order{
ID: id,
Customer: customer,
Status: StatusPending,
Items: []string{}, // so JSON shows [] instead of null
}, nil
}

// AuditedOrder composes Order by embedding it
type AuditedOrder struct {
Order
CreatedAt time.Time `json:"created_at"`
}

func NewAudited(id int, customer string) (*AuditedOrder, error) {
o, err := New(id, customer)
if err != nil {
return nil, err
}
return &AuditedOrder{Order: *o, CreatedAt: time.Now()}, nil
}
o, err := order.NewAudited(1, "Alice")
if err != nil {
log.Fatal(err)
}
fmt.Println(o.ID, o.Customer, o.CreatedAt) // promoted fields

11. Summary​

  • Go has no class; struct + methods express data and behavior. Initialize with composite literals (T{X: 1}) and specify field names.
  • A struct is a value type, copied on assignment and argument passing. Use a pointer when you need to modify it, it is large, or it must not be copied.
  • There are no constructors; New... functions are the convention. Aim to make the zero value useful.
  • Struct tags (`json:"..."`) control serialization, and only exported fields are affected.
  • There is no inheritance; use composition via embedding. Embedding offers neither polymorphism nor overriding, so use interface for polymorphism.
  • Express enum with type + const + iota and implement String(). Range checks are your responsibility.

Next, see Methods and Interfaces.

References​