Olha Stefanishyna
← Back to home

Go for Developers: Defined Types, Pointers and Functions

A minimalist abstract digital artwork representing nodes connected with directional lines in shades of beige and gray.
A minimalist abstract digital artwork representing nodes connected with directional lines in shades of beige and gray.

Introduction

In the previous part we covered the most common data structures in Go: arrays, slices, maps, and structs. We declared them using Type Literals, which are used to create Unnamed Types. Type Literals have their trade-offs, which will be discussed later in this article. This article will cover Defined Types, Pointers, and Functions.

Defined Types, are part of Named Types. You can look at the top-level split in the Basic Types section in the article.

Defined Types

In Go, defined types are the types you create yourself using the type keyword. Defined types prevent accidental misuse of semantically different values. A type definition creates a new, distinct type that is different from any other type, even if they share the same underlying structure:

go
1type UserID int32
2type ProductID int32
3
4var u UserID = 1
5var p ProductID = 1
6
7// compile error: mismatched types
8fmt.Println(u == p)

By treating UserID and ProductID as different types, Go prevents you from accidentally passing a product's ID into a function that expects a user's ID.

Using unnamed types is idiomatic for temporary data structures (like JSON payloads or table-driven tests), but they have two major considerations:

  • Two unnamed types are considered identical if they have the same structure.
  • You cannot attach methods to unnamed types.

Structs are where unnamed types become a real limitation when a struct is supposed to represent a meaningful entity in your solution: User, Order, Config. When two structs of unnamed type are declared with the same fields, the compiler treats them as the same type. Structs of unnamed type also can't contain methods, turning them into data containers, unable to have behaviour. Compare the same structure declared as defined types and as unnamed types:

go
1// Defined types
2type Point struct { X, Y float64 }
3type Velocity struct { X, Y float64 }
4
5// Compiler catches this, type mismatch is not allowed:
6var point Point = Velocity{1, 2}
7// ***
8// Type literal
9var point struct{ X, Y float64 } = struct{ X, Y float64 }{1, 2}
10var velocity struct{ X, Y float64 } = struct{ X, Y float64 }{5, 5}
11// This compiles silently, allowing cross-assignment of semantically different things!
12point = velocity // The compiler allows this!

At runtime, a defined type shares the exact same memory layout and representation as its underlying type. This makes the abstraction exceptionally memory-efficient.

Let's break it down.

Defined Types Under the Hood

A defined type is mostly a compile-time abstraction used for static analysis. When you declare type UserID int32, you are telling the compiler to register a new type based on an existing one (which is int32 here). During the compilation phase, the Go compiler uses the defined type to enforce strong typing and prevent logical errors. Once the compiler validates the type safety of your code, it lowers the defined type to its underlying representation. Also during the compilation phase, the compiler creates a type descriptor that contains metadata for the type.

What is a type descriptor? It is a struct whose layout is defined by abi.Type. Check the Go source on GitHub to learn which fields it contains. It is used by the Go runtime for determining type identity, dispatch interface method calls, support reflection, and assist garbage collection. This descriptor is stored in the executable's read-only data section - .rodata, it is not bundled with the variable itself during execution.

The resulting binary contains no additional metadata, headers, or pointers for the UserID variable. This variable occupies exactly 4 bytes, identical to a standard int32. Therefore, the generated machine code (ISA instructions) for a UserID is indistinguishable from that of an int32 that means named types incur zero runtime overhead as direct values (values stored directly in variables or fields, not wrapped in an interface).

Only defined types can have methods The Go Language Specification explicitly dictates that you can only declare methods on defined types. Under the hood, when you use a defined type, the compiler also appends an UncommonType block after the base abi.Type descriptor. That block contains metadata to the type's methods. Defined types always have it, even if the method set is empty.

Use the reflect package to observe the type descriptor directly:

go
1type UserID int32
2type ProductID int32
3
4user := UserID(1)
5product := ProductID(1)
6
7fmt.Println(reflect.TypeOf(user)) // main.UserID
8fmt.Println(reflect.TypeOf(user).Kind()) // int32
9fmt.Println(reflect.TypeOf(user).Size()) // 4
10// false — distinct descriptors
11fmt.Println(reflect.TypeOf(user) == reflect.TypeOf(product))

At runtime, the reflection package shows that both types have the same underlying type, but reflect.TypeOf returns different descriptors for each — they are distinct types regardless of their shared underlying type. At compile time, the compiler uses its type checker to provide type safety and same result.

Type Aliases

Besides a type definition there are type aliases in Go. Type alias creates another name for the same type:

go
1type Celsius = float64

Pointers

A pointer is a value that represents the memory address of another value. When you assign it to a variable or pass it to a function, you are working with a copy of that address. It allows you to directly access and modify data without creating a copy of that data.

Syntax

  • Pointer type syntax *T declares the type of the variable that the pointer will point to. T can be any type, including another pointer **int.
go
1var p *int // pointer to a variable of type int.
  • The Address-of Operator & is used in an expression to generate a pointer to a variable. It takes the address of the operand. You can only take the address of addressable values (variables, slice elements, fields of an addressable struct).
go
1x := 42
2p = &x // p holds the address of x

Note: The Go Spec defines addressability based on whether a value has a stable, fixed location in memory. Numeric, string, boolean literals, constants, function return values, intermediate values in expressions, map elements (like m[key]) are not addressable. Composite literals are the exception among literals: you can take the address of a struct literal directly because Go provides internal mechanism to make them addressable.

  • The Indirection Operator * is used in expressions to read from or modify the value a pointer points to. When you modify a value through a pointer, you are modifying the original data in memory.
go
1fmt.Println(*p) // Read: 42
2*p = 99 // Write: x is now 99

Why Pointers Exist

We already know that in Go everything is passed by value. For structs that means a full copy of all fields is made every time. For small structs that's fine. For large ones, or when you need a function to actually modify the original, you pass a pointer.

go
1type User struct {
2 Name string
3 Age int
4}
5
6func birthday(user *User) {
7 user.Age++
8}
9
10user := User{Name: "Alice", Age: 30}
11birthday(&user)
12
13fmt.Println(user.Age) // 31

In this example note that user.Age++ is used instead of (*user).Age++. user.Age is shorthand for (*user).Age when user is a pointer to a struct. The same applies to the array index operator: a[i] is shorthand for (*a)[i]. This keeps the code clean and lets you focus on logic.

Pointers Under the Hood

At the machine level, a pointer is an unsigned integer that holds a memory address. On modern 64-bit architectures (amd64, arm64), a pointer occupies 8 bytes of memory. At runtime when p := &x is executed, the 64-bit memory address of x is stored inside the memory allocated for p.

Because pointers are integers, how does Go runtime differentiate between an actual int64 representing a scalar number and a pointer representing the memory address after compilation? If the runtime guessed wrong, it can clean up memory that is still in use, or keep garbage memory forever. The compiler tracks this statically and embeds the information in type metadata. The GC then uses that metadata.

Unlike conservative collectors that guess which values are pointers, Go knows with 100% certainty. The compiler generates a bitmap and a pointer to the bitmap and stores it in the type metadata - abi.Type descriptor in GCData field. This bitmap itself is an array of bytes, it tells the GC exactly which bytes of a data structure contain pointers and which contain scalar values:

text
1Bit = 0 means the 8-byte word is a scalar value.
2Bit = 1 means the 8-byte word is a pointer.

This metadata allows the GC to be both precise and concurrent, safely scanning the memory while an application continues to run.

Pointer Arithmetic

In languages like C, you can shift a pointer by a few bytes to traverse memory manually (p++). It's powerful, but this is the primary source of buffer overflows and security vulnerabilities. Go explicitly forbids pointer arithmetic and only allows pointing at compiler-verified addresses. This way, pointers always stay aligned with the bitmap and The Garbage Collector never has to worry about lost pointers.

(Note: Go does provide the unsafe package for bypassing the type system and performing pointer arithmetic, using it a developer takes full responsibility for following the GC's expectations).

Escape Analysis

In C, returning a pointer to a locally scoped variable can cause bugs and be a source of security vulnerabilities (use-after-free exploits) because the variable's memory is destroyed as soon as the function returns (the dangling pointer).

In Go, the compiler performs Escape Analysis.

go
1func createConfig() *Config {
2 config := Config{Port: 8080}
3 return &config
4}

During compilation, Go analyzes the lifetime of the config variable. Because it detects that the address of config escapes the createConfig function and is returned to the caller, the compiler allocates config on the heap instead of the function's stack. You get the safety of a managed language without having to think about heap allocation.

Functions

Functions are first-class values in Go. This means they have a type, they can be assigned to variables, passed as arguments to other functions, and returned from functions.

Syntax

  • Function Declaration: The standard way to define a block of code. Calls to them are direct and resolved at compile-time.
go
1func add(a int, b int) int {
2 return a + b
3}
  • Parameter Type Elision: When consecutive parameters share a type, the type can be written once. This reduces redundancy in long signatures.
go
1func add(a, b int) int {
2 return a + b
3}
  • Multiple Return Values: Go functions can return an arbitrary number of values. If more than one value is returned, the types must be wrapped in parentheses.
go
1func minMax(a, b int) (int, int) {
2 if a < b {
3 return a, b
4 }
5 return b, a
6}
7
8min, max := minMax(12, 7)

Assignment requires the same number of variables on the left.

  • Named Return Parameters: You can treat return values as variables scoped to the function body by declaring them as variables at the top of the function scope. A bare return returns their current values.
go
1func minMax(a, b int) (min, max int) {
2 if a < b {
3 // assigning to the named return variables
4 min = a
5 max = b
6 } else {
7 min = b
8 max = a
9 }
10 // bare return — automatically returns min and max
11 return
12}
  • Variadic function: accepts a number of arguments that can vary. The type of the last parameter is prefixed with ... . It allows the function to accept zero or more arguments of that type. Inside the function, it behaves as a slice.
go
1func sum(nums ...int) int {
2 total := 0
3 for _, n := range nums { total += n }
4 return total
5}

Passing a Slice to a Variadic Parameter (The ... Suffix): If you already have a slice and want to pass its elements to a variadic function, you must suffix the argument with .... This tells the compiler to pass the slice's underlying array directly instead of creating a new slice and nesting your existing slice inside it.

go
1values := []int{1, 2, 3}
2sum(values...)

Calling a variadic function usually triggers a heap allocation for the underlying array of that slice unless the compiler can prove it doesn't escape.

  • Deferred Function Calls: The defer keyword schedules a function call to run when the surrounding function returns, regardless of how it returns — normally or via panic. Multiple deferred calls execute in last-in, first-out order. It is idiomatic Go for cleanup: closing files, releasing locks, closing database connections. Arguments passed to a deferred function are evaluated when the defer is reached, not when the function finally runs.
go
1func readFile(path string) error {
2 f, err := os.Open(path)
3 if err != nil {
4 return err
5 }
6 defer f.Close()
7
8 // ... work with f
9 return nil
10}

Closures

A closure is a function literal defined inside another function that can read and write variables from the enclosing scope. A closure doesn't take a copy of the variable. Instead it holds a pointer to the variable it captures. When you access a captured variable, the compiler generates an automatic indirection: it follows the pointer stored in the closure struct to reach the variable's actual location on the heap (which is hoisted to the heap if the closure escapes the local scope).

go
1func counter() func() int {
2 count := 0
3 return func() int {
4 count++
5 return count
6 }
7}

Each call to counter() creates a new count variable. The returned function closes over it, meaning it holds a reference to that specific variable, not a copy. Nothing new for a JavaScript developer.

Functions as Values

The moment you treat a function as a piece of data, you create a function value. This happens when you:

  • Assign it to a variable: myFunc := add
  • Pass it as an argument: process(add)
  • Return it from a function: return add
  • Declare as a function literal assigned to a variable var add = func(a int, b int) int { ... }

Function values have types. A function's type is entirely determined by its signature: the parameters they take and the value it returns.

go
1func add(a, b int) int { return a + b }
2func multiply(a, b int) int { return a * b }

Both add and multiply have the type func(int, int) int. This means they both equally can be used anywhere where that type is expected.

Use the reflect package to observe a function's type descriptor directly:

go
1// true — same signature, same descriptor
2fmt.Println(reflect.TypeOf(add) == reflect.TypeOf(multiply))

Functions Under the Hood

Function types

When the compiler encounters a new function signature, it registers a FuncType descriptor in the binary's read-only memory. This descriptor extends the base abi.Type with two extra fields: InCount (number of parameters) and OutCount (number of return values). However, counts alone aren't enough: func(int) and func(string) both have one parameter, means they both would have same signature. To fully define the signature, Go places an array of pointers immediately after the FuncType in memory, pointing to the exact types of each parameter and return value. Two functions with identical signatures will share this exact same FuncType descriptor.

Functions in Memory, funcval

Every function body compiles down to a fixed address in the binary's .text section where the machine instructions live. When you call a named function directly, the compiler jumps straight to a fixed address in the binary (.text). But a function variable does not hold a raw pointer to machine code. It holds a pointer to an internal structure called funcval. The first field of this struct is the address of the actual machine instructions (in .text).

go
1function variable → funcval{ fn: → machine code }

Why This Indirection Exists: Go must handle both plain functions and closures through the same mechanism so that caller doesn't need to know the difference. When a closure captures variables, the compiler performs escape analysis. If that variable might outlive the function that created it, the compiler hoists that variable from the stack to the heap and appends pointers to them directly into the funcval struct after the code pointer.

go
1x := 10
2
3add := func(a int) int {
4 return a + x // captures x
5}

The funcval for this closure looks like:

text
1funcval {
2 fn → machine code
3 x → pointer to heap-allocated `x`
4}

By making every function variable a pointer to a funcval, Go guarantees that plain functions and closures look identical to the caller. The caller always does the same thing: follow the pointer to funcval, call fn. Any variables closed over sit after that field inside the funcval, the called function reads them via the context register.

Defined Types determine the memory layout and size of data. Pointers, and the Escape Analysis they trigger, determine whether data lives on the stack or heap, and how the Garbage Collector scans that memory. Functions define the entry points for the CPU's instruction pointer and how the context is loaded into registers. Together, they dictate how your data is represented, how memory is managed, and how your logic is executed.

In the next part, we will explore methods and interfaces. We will see how Go achieves structural polymorphism by combining defined types and pointers to create flexible, decoupled designs.

Next article: Coming soon

Let's talk