1. Why do you need “your own types” if you already have int
When you are just starting to program, int feels like a universal hammer: you can drive any nail with it, and if the nail is a string, you can even hammer it in with the string (don’t ask how). The problem is that in real programs different numbers mean different things: “task status”, “access level”, “error code”, “number of days”. And if you store all of that as just int, the compiler won’t be able to protect you from accidentally mixing up meanings.
Imagine we have a training console app TaskBox (we “level it up” gradually through examples). For now it is very simple: we read the task id and its status (as a number) from input, and then print a clear message.
With a “bare int”, it is easy to write something weird and not even notice:
package main
import "fmt"
func main() {
var taskID int = 42
var status int = 2
// Oops... we accidentally swapped them:
status = taskID
fmt.Println(status) // 42 (and the compiler does not complain)
}
From the compiler’s point of view, everything is legal: both values are int. From the meaning’s point of view, it is a silent disaster (at the very least, a weird bug).
This is where custom number-based types come in: we create a new type that is stored as a number, but has its own meaning and its own compatibility rules.
2. How type Status int works
What type Status int creates
Right now it is important not to “skim past” the essence. The line type Status int is not a variable and not a constant. It is a declaration of a new type. It will have the same “internal representation” as int, but for the compiler it is a different entity, and it will stop letting you mix it with any int without explicit permission.
This technique in Go is absolutely normal practice. Even in official materials, you often see the idea of a “semantic” numeric type like type Pill int (a “pill” type as a number) so that you can then define a set of named values.
A minimal example:
package main
import "fmt"
func main() {
type Status int
var s Status = 1
fmt.Printf("%T %v\n", s, s) // main.Status 1
}
Notice: the type is shown as main.Status, not int. This is not cosmetic—it is the compiler hinting: “hey, this is a separate type.”
Status and int are different types
Now the most useful part: we will show exactly how Go starts protecting you.
Let’s create Status and try to assign a regular int to it without a conversion:
package main
import "fmt"
func main() {
type Status int
var code int = 2
var s Status = code // not allowed: different types
fmt.Println(code) // 2
}
This line (var s Status = code) will not compile. And that is good: the compiler forces you to say out loud (in code): “Yes, I am consciously converting int into Status.”
Same thing in the other direction:
package main
import "fmt"
func main() {
type Status int
var s Status = 2
var code int = s // also not allowed
fmt.Println(s) // 2
}
Yes, it looks “strict”. But that strictness is like a seat belt: it feels tight at first, then it saves you.
By the way, this is exactly how a type definition differs from “just another name”. Go also has type aliases, but type Status int creates a new (defined) type with its own assignment rules.
Explicit conversion: Status(code) and int(s)
Once the compiler forbids direct assignment, the next step is to learn explicit conversion. We have already dealt with this before when converting between numeric types. The same principle applies here: Go has no “implicit casting”—you write explicitly what is happening.
Example: read a number as int, convert it to Status.
package main
import "fmt"
func main() {
type Status int
var code int = 2
var s Status = Status(code) // explicit conversion
fmt.Printf("code=%d (%T)\n", code, code) // code=2 (int)
fmt.Printf("s=%d (%T)\n", s, s) // s=2 (main.Status)
}
And the reverse conversion:
package main
import "fmt"
func main() {
type Status int
var s Status = 2
code := int(s) // convert back to int
fmt.Printf("code=%d (%T)\n", code, code) // code=2 (int)
}
Why is this not “unnecessary fuss”? Because when reading the code you can see the semantic boundary. It is like a sign saying “careful, different mode ahead”: you won’t confuse “status” and “task id” if they are different types.
Named constants for Status
When numbers like 0, 1, 2 start showing up in code, you very quickly hit a classic bug: a week later nobody remembers what 2 means. “Is it done? Or in progress? Or ‘everything broke’?”—and then archaeology begins.
It is better to give these values names using const. Now we will do it without iota, because iota is a separate topic for the next lecture. Here we write values explicitly so everything is максимально transparent.
package main
import "fmt"
func main() {
type Status int
const (
StatusNew Status = 0
StatusInProgress Status = 1
StatusDone Status = 2
)
var s Status = StatusDone
fmt.Println(s) // 2
}
Notice two things.
First: the constants are typed as Status, not as int. This helps the compiler keep protecting us.
Second: now comparisons can be written in a human-friendly way:
package main
import "fmt"
func main() {
type Status int
const StatusDone Status = 2
var s Status = 2
if s == StatusDone {
fmt.Println("task is done") // task is done
}
}
Yes, s prints as 2, but in code you read not “2”, but StatusDone. Meaning matters more than the digit.
TaskBox example: reading a status and printing text
Now we will assemble a small piece of real code that looks like part of a normal app. Reminder: we do not have structs and functions yet (other than main), so we do everything straightforwardly.
We want this behavior: the user enters taskID and statusCode, and the program prints clear text. This is exactly where a custom type Status is convenient, so that we don’t drag “raw ints” through the entire logic.
package main
import (
"fmt"
)
func main() {
type Status int
const (
StatusNew Status = 0
StatusInProgress Status = 1
StatusDone Status = 2
)
var taskID int
var statusCode int
fmt.Scan(&taskID, &statusCode)
status := Status(statusCode) // explicit conversion int → Status
if status == StatusNew {
fmt.Printf("task #%d: new\n", taskID) // for example: task #10: new
} else if status == StatusInProgress {
fmt.Printf("task #%d: in progress\n", taskID)
} else if status == StatusDone {
fmt.Printf("task #%d: done\n", taskID)
} else {
fmt.Printf("task #%d: unknown status (%d)\n", taskID, statusCode)
}
}
There is an important habit here that is worth internalizing.
We do not trust the input number. We convert it to Status, but we still assume it could be “garbage”, so we keep an else branch for unknown values.
Why is this important? Because the fact of Status(statusCode) does not guarantee the value is “valid”. The conversion only says: “We now treat this number as a status.” It does not say: “This status is from our list.”
Why Status is sometimes compared to a number
Now comes the “seems like magic, but it is actually rules” moment.
In Go, there are untyped constants. They can “adapt to context”—you saw this in the lecture about typed/untyped constants.
Because of this, code like this often compiles:
package main
import "fmt"
func main() {
type Status int
var s Status = 2
if s == 2 {
fmt.Println("looks like done") // looks like done
}
}
Why? Because 2 here is an untyped constant, and the compiler can “try it on” as Status (if the value is representable). This is sometimes convenient in tiny examples, but in real code it quickly turns into “magic numbers” and hurts readability.
For learning discipline and future projects, it is better to keep this rule: compare only with named constants:
package main
import "fmt"
func main() {
type Status int
const StatusDone Status = 2
var s Status = 2
if s == StatusDone {
fmt.Println("done") // done
}
}
Meaning is read with your eyes, not from your memory of “what that 2 meant last month”.
3. When you need your own numeric type
It is useful to mentally separate “just numbers” and “numbers with meaning”. type Status int is needed precisely in the second category.
Below is a small table (not for rote memorization, but to make decisions easier):
| Situation | What to choose | Why |
|---|---|---|
| Calculating a sum, difference, number of elements, minutes/seconds in a computation | |
This is “math”; the meaning is usually obvious from the formula context |
| Storing a state code (task status, access level, operating mode) | |
The compiler protects you from mixing meanings; the code is more readable |
| Storing an “identifier” (user id, task id) | |
Technically it is also a number, but the meaning is different; mixing it with other numbers is dangerous |
| Reading a value from input and using it as a “semantic code” | |
Input is raw data, while application logic uses semantic types |
In our TaskBox, we already felt the benefit: status stopped being “just a number”, and now it has a separate name and separate rules.
4. Common mistakes
Mistake #1: expecting that type Status int is “just another name for int”.
Beginners often think it is like a nickname: since it is still a number inside, you can assign int to Status and back without problems. But a type definition in Go is intentionally strict: Status and int are different types, and the compiler will not let you mix them. This is not the language being mean; it is protection from accidental mistakes.
Mistake #2: performing a conversion and assuming the value is automatically “valid”.
The construct Status(code) does not check that code equals 0, 1, or 2. It simply changes the type “label”. Therefore, when reading from input (or from any external system), you always need a plan for unknown values: an else branch or a default (if you use switch).
Mistake #3: continuing to compare statuses to bare numbers (if s == 2).
Sometimes it compiles because of untyped constants, and it feels like “well, then it is fine”. But the meaning is lost: after a couple of days, 2 becomes a mystery. If we introduced a semantic type, it makes sense to carry the idea through and compare with StatusDone, StatusNew, and so on.
Mistake #4: mixing different “categories of numbers” in a single variable.
For example, storing a status, then an id, then “whatever came from input” in status because “it is all int anyway”. This almost always creates bugs that look like random accidents. Custom types help keep order: separately “what came in” (int), separately “meaning in the program” (Status).
Mistake #5: choosing names that are too generic (type Code int).
If you name the type just Code, it will quickly start meaning “anything” and lose its value. It is better to choose a name that reflects the meaning: Status, Perm, TaskState. Then the code reads without “translating” from numeric to human.
GO TO FULL VERSION