Go
June 12, 2026

Choosing Go Types That Keep Enterprise Services Correct and Efficient

Practical Introduction

A production service is built from small decisions. One of the first decisions is also one of the easiest to underestimate: which type should hold this value?

In Go, types are not just labels for the compiler. They communicate business intent, influence memory usage, prevent invalid states, and make integration with other systems more predictable. A delivery dispatch service, for example, may need to handle courier IDs, parcel fees, package weights, feature flags, approval states, binary scanner messages, and collections of active deliveries. Each value has different rules.

The practical goal is simple: model data so that the compiler helps you, future readers understand the code quickly, and the runtime avoids unnecessary work.

Context and Scope

Imagine a small Go service that assigns parcels to couriers. The system has a few moving parts:

HTTP API (application programming interface) request
  |
  v
Input decoder
  |
  v
Validation and assignment service
  |
  v
In-memory indexes and storage adapter
  |
  v
Response or background processing

The service receives inputs such as parcel IDs, package weight, delivery fee, courier capabilities, and operational status. It must produce clear decisions: accept the parcel, reject invalid input, mark a courier as unavailable, or store data for later processing.

This is where Go's core language elements matter. The difference between int64 and float64, bool and *bool, []byte and string, or map[string]bool and map[string]struct{} can affect correctness, performance, and maintainability.

Start with Domain Meaning, Then Pick the Type

The first mistake many developers make is choosing types mechanically. They use int64 for every number, string for every identifier, and float64 for anything that looks numeric. That works for a toy program, but it creates weak contracts in a larger code base.

A better workflow is:

  1. Decide what the value represents.
  2. Decide whether negative values are valid.
  3. Decide whether the value must be exact or approximate.
  4. Decide whether the value crosses a boundary such as an API, file format, or protocol.
  5. Decide whether absence is different from a zero value.

Here is a compact model for a delivery service:

package dispatch

type CourierID string
type ParcelID string

type DeliveryStatus uint8

const (
	StatusUnknown DeliveryStatus = iota
	StatusWaiting
	StatusAssigned
	StatusDelivered
)

type Parcel struct {
	ID        ParcelID
	WeightKg  float64
	FeeCents  int64
	Fragile   bool
	Status    DeliveryStatus
}

type Courier struct {
	ID          CourierID
	MaxParcels  uint16
	CapacityKg  float64
	InService   bool
}

A few choices are intentional:

  • ParcelID and CourierID are named string types, so the code does not casually mix unrelated IDs.
  • FeeCents uses int64, because money should be stored in the smallest indivisible unit rather than as a floating-point number.
  • WeightKg uses float64, because measurement values can tolerate small representation differences.
  • DeliveryStatus uses a small unsigned integer type, because the valid states are compact and non-negative.
  • InService uses bool, because it has exactly two states.

Choosing Numeric Types Without Guesswork

Go gives you several integer types. That is useful, but it also means you must choose deliberately.

Need Practical Go choice Reason
General counters and loop indexes int Natural default for everyday counting
External fixed-width values int32, int64, uint16, uint32 Matches storage formats, protocols, or APIs
Values that must not be negative uint, uint16, uint32 Makes invalid negative values impossible at the type level
Money in cents or smallest units int64 Keeps arithmetic exact
Measurements and averages float64 Good default for real-number calculations
Raw binary data []byte or byte byte clearly means raw data, not a business number
Unicode code points rune Communicates character-level processing

Use int for routine counting. Use fixed-width types when data must match a known external representation. Use smaller types only when the memory saving matters, such as very large in-memory datasets or compact event formats.

Be careful with unsigned types. They are useful when negative values are invalid, but mixing signed and unsigned values often forces explicit conversions. That explicitness is part of Go's safety model, but it also means you should avoid unsigned numbers unless they express a real rule.

Floats Are for Measurements, Not Exact Accounting

Floating-point values are convenient for speed, temperature, fuel usage, distance, and similar measurements. They are not suitable for money, inventory counts, or any value where every unit must be exact.

Use integer arithmetic for exact quantities:

package main

import "fmt"

func main() {
	feeCents := int64(1299)
	discountCents := int64(250)
	finalCents := feeCents - discountCents

	fmt.Println("final fee in cents:", finalCents)

	averageWeightKg := 7.45
	fmt.Println("average package weight:", averageWeightKg)
}

1299 cents is exact. 12.99 as a floating-point value is not guaranteed to behave like decimal money across repeated calculations. This matters when a system processes many transactions or generates reports from accumulated values.

Booleans, Three-State Logic, and Constants

A bool should represent a true yes-or-no state. Go does not treat 0, 1, empty strings, or non-empty strings as booleans. A condition must be explicitly true or false, which keeps decision logic readable.

Sometimes two states are not enough. A courier may have accepted a delivery, rejected it, or not responded yet. A plain bool cannot represent that third state.

Use *bool when absence has business meaning:

package dispatch

func AcceptedText(accepted *bool) string {
	if accepted == nil {
		return "not answered"
	}
	if *accepted {
		return "accepted"
	}
	return "rejected"
}

This is clear at API boundaries and form-like inputs. For high-volume internal data, a small integer enum may be more compact:

package dispatch

type ReplyState int8

const (
	ReplyUnknown ReplyState = iota
	ReplyAccepted
	ReplyRejected
)

iota is useful for related constants. It starts at zero inside a constant block and increments on each line. Use it for status values, categories, and compact internal vocabularies. Be careful when these values are stored outside the process. Reordering constants later can silently change the meaning of old data.

Bit Flags for Compact Feature Sets

When a value can have several independent yes-or-no capabilities, bit flags can be more compact than many booleans. Each flag uses one bit inside an integer.

package dispatch

type CourierFeature uint8

const (
	FeatureColdBox CourierFeature = 1 << iota
	FeatureHeavyLoad
	FeatureSignaturePad
)

func HasFeature(mask, feature CourierFeature) bool {
	return mask&feature != 0
}

func ExampleFeatures() bool {
	features := FeatureColdBox | FeatureSignaturePad
	return HasFeature(features, FeatureSignaturePad)
}

The | operator combines flags. The & operator checks whether a flag is present. This is efficient for permissions, device capabilities, and feature toggles, but document the meaning of each flag. Bitwise code is fast, but unclear flags are hard to maintain.

Variables, Zero Values, and Shadowing

Go variables are always initialized. If you do not provide a value, Go assigns the zero value:

  • Numbers become 0.
  • Booleans become false.
  • Strings become an empty string.
  • Pointers, slices, maps, channels, functions, and interfaces become nil.

This removes a class of uninitialized-variable bugs, but it does not remove the need to model meaning. An empty string may mean missing input, or it may be valid. A nil slice may mean not loaded yet, or it may mean an empty collection. Decide what the zero value means for each type.

Use var when you need declaration without immediate initialization or when declaring at package scope. Use := inside functions when declaring and assigning a new local variable.

Watch for shadowing:

package dispatch

func UpdateStatus(current DeliveryStatus, input string) DeliveryStatus {
	if input == "delivered" {
		current := StatusDelivered
		return current
	}
	return current
}

Inside the if, current := StatusDelivered creates a new local variable instead of updating the outer one. In this tiny example, returning it immediately is harmless. In longer functions, accidental shadowing can create bugs that are difficult to see during code review. Use = when you mean to update an existing variable.

Keep variables in the smallest practical scope. Shorter scope makes code easier to read and can reduce how long memory must stay alive.

Pointers: Use Them for Sharing, Mutation, and Optional Values

A pointer stores the address of another value. The & operator gets an address. The * operator dereferences a pointer to read or update the value at that address.

package dispatch

type DeliveryPlan struct {
	ParcelID ParcelID
	Courier  CourierID
	Notes    [512]byte
	Status   DeliveryStatus
}

func MarkAssigned(plan *DeliveryPlan, courier CourierID) {
	if plan == nil {
		return
	}
	plan.Courier = courier
	plan.Status = StatusAssigned
}

Passing *DeliveryPlan avoids copying a larger struct and allows the function to change the original value. This is appropriate when a value is identity-like, large, or intentionally mutable.

Prefer values when the type is small and you do not need mutation. Copying a small struct is often clearer than passing a pointer everywhere. Pointers add indirection, can increase heap allocation, and require nil checks in production code.

Good pointer use cases:

  • Mutating a struct in a function or method.
  • Avoiding repeated copies of large structs.
  • Representing optional values with nil.
  • Sharing one object across layers intentionally.

Weak pointer use cases:

  • Passing every primitive as *int or *bool without needing nil.
  • Using pointers to look clever.
  • Avoiding copies before measuring whether copies are a problem.

Strings and Bytes

A Go string is immutable text stored as UTF-8 bytes. Copying a string variable is cheap because the value behaves like a small header pointing to underlying bytes. Modifying a string is not possible in place. Building a new string creates new data.

Use string for human-readable text. Use []byte for raw data, network buffers, file contents, or scanner packets. Converting between them copies data, so avoid unnecessary conversions in hot paths.

For repeated string building, use strings.Builder rather than repeated concatenation in a loop.

package dispatch

import "strings"

func BuildLabel(parts []string) string {
	var b strings.Builder
	for i, part := range parts {
		if i > 0 {
			b.WriteString("-")
		}
		b.WriteString(part)
	}
	return b.String()
}

When processing user-facing text, remember that a byte is not always a character. Use rune when you need Unicode code points. Use byte-level loops only for binary work or low-level performance-sensitive processing.

Slices, Arrays, and Maps

Arrays in Go have fixed length, and the length is part of the type. Assigning an array copies the whole value. Arrays are useful for fixed-size data such as protocol fields, hash-like values, or compact buffers.

Most application code uses slices. A slice is a view over an array. It has a pointer, length, and capacity. Because slices share backing arrays, changing one slice can affect another slice that views the same data.

package dispatch

func FirstBatch(parcels []Parcel, n int) []Parcel {
	if n > len(parcels) {
		n = len(parcels)
	}
	batch := make([]Parcel, n)
	copy(batch, parcels[:n])
	return batch
}

This copies the selected parcels into a new slice. Copying is useful when the returned data must not keep a large backing array alive or must be isolated from later mutation.

Always assign the result of append:

parcels = append(parcels, Parcel{ID: "P-1001", FeeCents: 850})

append may reuse the existing backing array or allocate a new one. The returned slice is the updated slice header, so ignoring it is a bug.

Maps are Go's built-in hash tables. They are excellent for lookups and grouping, but they have important rules:

  • Use the comma-ok form when a zero value could be ambiguous.
  • Do not rely on iteration order.
  • Preallocate when you know the approximate size.
  • Do not write to the same map from multiple goroutines without synchronization.
package dispatch

func IndexByParcelID(parcels []Parcel) map[ParcelID]Parcel {
	index := make(map[ParcelID]Parcel, len(parcels))
	for _, p := range parcels {
		index[p.ID] = p
	}
	return index
}

func UniqueDepotCodes(codes []string) map[string]struct{} {
	set := make(map[string]struct{}, len(codes))
	for _, code := range codes {
		set[code] = struct{}{}
	}
	return set
}

map[string]struct{} is the idiomatic set pattern when only presence matters. A struct{} value carries no payload, which makes the intent clearer than map[string]bool when you do not need a true-or-false value per key.

Explicit Type Conversion

Go does not silently convert between types. This is deliberate. A conversion can lose precision, truncate data, copy memory, or reinterpret a value. Go requires you to write that decision at the call site.

package main

import "fmt"

func main() {
	var count32 int32 = 120
	count64 := int64(count32)

	weight := 12.95
	wholeKg := int(weight)

	packet := []byte("PICK")
	text := string(packet)
	copyArray := [4]byte(packet)
	sharedArray := (*[4]byte)(packet)

	sharedArray[0] = 'T'

	fmt.Println(count64, wholeKg, text, copyArray, string(packet))
}

The int(weight) conversion removes the fractional part. The string(packet) conversion copies bytes into a string. The slice-to-array value conversion copies into a new array, while the slice-to-array pointer conversion shares the backing data. Use the pointer form only when you intentionally want shared memory and you can control the lifetime of that data.

A Practical Workflow for New Code

When adding a new field, function, or collection, use this workflow:

  1. Name the domain rule first. For example: delivery fee, courier capacity, approval response, scanner bytes.
  2. Choose the smallest type that is still safe and clear.
  3. Decide whether the zero value is valid.
  4. Use constants for fixed states and repeated meanings.
  5. Keep variables local unless they truly need wider scope.
  6. Prefer values for small immutable data and pointers for large or mutable data.
  7. Preallocate slices and maps when the expected size is known.
  8. Copy slices or byte buffers when isolation matters.
  9. Make every conversion explicit and intentional.
  10. Add a focused test or benchmark when memory or speed is part of the reason.

Useful commands when validating performance-sensitive choices:

go test -bench=. -benchmem
go build -gcflags="-m" ./...

Benchmarks help compare allocation patterns. Escape analysis output helps reveal when values move to the heap. Do not optimize blindly, but do measure when a type decision is based on performance.

Common Mistakes to Watch For

  • Using floating-point values for money or exact counts.
  • Choosing int64 for everything without considering memory volume.
  • Using bool when the domain has three or more states.
  • Reordering iota constants after values have been stored externally.
  • Accidentally shadowing variables with :=.
  • Passing small values by pointer without a clear reason.
  • Ignoring the returned slice from append.
  • Holding a small sub-slice of a large buffer and keeping the whole buffer alive.
  • Treating map iteration order as stable.
  • Writing to a map concurrently without protection.
  • Converting between string and []byte repeatedly in a hot path.

Checklist

Before merging Go code that introduces new types or collections, check the following:

  • Does each field type express the business rule clearly?
  • Are exact values stored as integers rather than floats?
  • Are measurements using float64 unless there is a clear reason for float32?
  • Are optional fields modeled explicitly with pointers or small enums?
  • Are constants used instead of magic numbers and unclear strings?
  • Are variable scopes as small as practical?
  • Are pointers used only for mutation, sharing, large structs, or optional state?
  • Are slices and maps preallocated when size is predictable?
  • Is copied data copied intentionally, especially around slices, strings, and byte buffers?
  • Are type conversions visible and reviewed for truncation, copy cost, or shared memory?

Conclusion

Go's core language elements are small, but they shape the behavior of the whole service. Good type choices make invalid states harder to represent. Good variable scope reduces confusion and unnecessary lifetime. Good pointer use balances clarity with performance. Good collection handling prevents hidden memory retention and unstable behavior.

Treat types, variables, operators, and collections as design tools, not syntax trivia. When you model data carefully at the beginning, the rest of the system becomes easier to test, review, and maintain.

Share:

Comments0

Home Profile Menu Sidebar
Top