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 / record | struct |
| Inheritance | Composition via embedding |
| Constructors | New... factory functions |
enum | const + iota |
Object initializer new T { X = 1 } | Composite literal T{X: 1} |
with expression on records | Copy the struct and change fields |
Attributes ([JsonPropertyName("x")]) | Struct tags (`json:"x"`) |
Type alias using X = Y | Type alias type X = Y |
readonly / init | None (use unexported fields + a constructor) |
Equals / == | Comparable structs support == |
partial class | None (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)
// 2. Positional (all fields required; breaks when fields are added, so discouraged)
// 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:
| Situation | Choice |
|---|---|
| You need to modify the value | Pointer |
| The struct is large (copy cost matters) | Pointer |
It contains fields that must not be copied, such as sync.Mutex | Pointer |
| Small, immutable values (coordinates, money, timestamps) | Value |
| When unsure | Use 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
==(usereflect.DeepEqual,slices.Equal, ormaps.Equal) - Comparable structs can also be used as
mapkeys
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)
| Tag | Meaning |
|---|---|
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.Jsonconverts 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 withomitempty
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# inheritance | Go embedding | |
|---|---|---|
| Relationship | is-a | has-a (plus delegation sugar) |
| Polymorphism | Assignable to a base class variable | Not possible (an Article cannot be assigned to Base) |
| Overriding methods | virtual / override | Define a same-named method on the outer type to shadow it |
| Inner calling outer's method | Possible (virtual dispatch) | Impossible (the inner type does not know the outer) |
| Multiplicity | Single inheritance | Multiple 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() stringmakesfmt.Printlnprint a human-readable form (theStringerinterface) - 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 bareiotaintegers 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
classleads to changes that "don't stick" - Looking for inheritance: embedding is not inheritance and has no polymorphism. Use
interfacefor 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
jsontags - Nil slices / maps as fields: writing to a zero-value
mappanics.makeit 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
readonlyorinit: express immutability with unexported fields and exported methods (getters) - Getters do not use a
Getprefix: for a fieldname, the getter isName()and the setter isSetName()
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
structis 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
interfacefor polymorphism. - Express
enumwithtype+const+iotaand implementString(). Range checks are your responsibility.
Next, see Methods and Interfaces.