1. Why do we need “our own types” if we already have int
When you are just starting to program, int looks like a universal hammer: you can use it to drive in any nail, and if the nail is a string, you can even drive it in with a string (do not 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 simply as int, the compiler cannot protect you from accidentally mixing meanings.
Imagine that we have a training console application called TaskBox (we will gradually “upgrade” it with examples). For now it is very simple: we read a task id and its status from input as a number, and then print a clear message.
With plain int, it is easy to write something strange and not even notice it:
package main
import "fmt"
func main() {
var taskID int = 42
var status int = 2
// Oops... 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 point of view of meaning, this is a quiet disaster (at the very least, a weird bug).
And this is where user-defined numeric types come in: we create a new type that is stored as a number, but has a separate meaning and separate compatibility rules.
2. How type Status int works
What does type Status int create
It is important not to miss the point here. 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 allowing you to mix it with any int without explicit permission.
This approach in Go is completely 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 printed as main.Status, not int. This is not cosmetic — it is the compiler’s hint: “friend, this is a separate type”.
Status and int are different types
Now for the most useful part: let’s show exactly how Go starts protecting you.
Let’s create Status and try to assign a normal int to it without 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 turning int into Status”.
The same is true in the opposite direction:
package main
import "fmt"
func main() {
type Status int
var s Status = 2
var code int = s // this is not allowed either
fmt.Println(s) // 2
}
Yes, it looks “strict”. But this strictness is like a seat belt: it feels tight at first, then it saves you.
By the way, this is exactly what distinguishes type definition from “just another name”. In Go, there are also type aliases, but type Status int creates precisely a new (defined) type with its own assignment rules.
Explicit conversion: Status(code) and int(s)
When the compiler has forbidden direct assignment, the next step is to learn explicit conversion. We have already encountered this before when converting between numeric types. Here the principle is the same: there is no automatic conversion in Go — you write explicitly what happens.
Example: read a number as int, then 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 “extra fuss”? Because when you read the code, you see the semantic boundary. It is like a sign saying “careful, a different mode starts here”: you will not confuse a “status” with a task id if they are different types.
Named constants for Status
When numbers 0, 1, 2 start appearing in code, you quickly run into the classic bug: a week later nobody remembers what 2 means. “Was it done? Or in progress? Or ‘everything is broken’?” — and archaeology begins.
It is better to give these values names with const. Here we will do it without iota, because iota is a separate topic for the next lecture. Here we write the values explicitly so that everything is as transparent as possible.
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 is printed as 2, but in code you read not “2”, but StatusDone. Meaning matters more than the digit.
TaskBox example: input status and output text
Now let us put together a small piece of real code that looks like part of a normal application. Reminder: we still do not have structs and functions (except main), so we will keep things straightforward.
We want this behavior: the user enters taskID and statusCode, and the program prints a clear text. This is exactly where a user-defined Status type is useful, so that we do not drag “raw int” 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 int → Status conversion
if status == StatusNew {
fmt.Printf("task #%d: new\n", taskID) // e.g.: 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 that it may be “garbage”, and therefore we keep the else branch for unknown values.
Why is this important? Because the fact of Status(statusCode) does not guarantee that the value is “valid”. The conversion only says: “This number is now treated as a status.” It does not say: “This is a status from our list.”
Why Status sometimes compares with a number
Now comes the “looks like magic, but actually it is 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 that, 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 follow the 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
}
}
The meaning is read by the eyes, not by memories of “what that 2 was last month”.
3. When do you need your own numeric type
It is useful to mentally separate “just numbers” and “numbers with meaning”. type Status int belongs to the second category.
Below is a small table (not for memorization, but to make decisions easier):
| Situation | What to choose | Why |
|---|---|---|
| We calculate a sum, difference, element count, minutes/seconds in a computation | |
This is “math”; the meaning is usually obvious from the formula context |
| We store a state code (task status, access level, operating mode) | |
The compiler protects us from mixing meanings, and the code is more readable |
| We store an “identifier” (user id, task id) | |
It is technically also a number, but the meaning is different; mixing it with other numbers is dangerous |
| We read a value from input and use it as a semantic code | |
Input is raw data, while logic uses semantic types |
In our TaskBox, we have already felt the benefit: the status stopped being “just a number”, and now it has its own name and its own rules.
4. Typical mistakes
Mistake #1: expecting that type Status int is “just another name for int”.
Beginners often think this is like an alias: since it is still a number inside, you can freely assign int to Status and back. But type definition in Go is deliberately strict: Status and int are different types, and the compiler will not let you mix them. This is not the language being picky, but protection from accidental mistakes.
Mistake #2: doing a conversion and assuming the value is automatically “valid” now.
The construction 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 default (if you use switch).
Mistake #3: continuing to compare statuses with bare numbers (if s == 2).
Sometimes this compiles because of untyped constants, and it creates the impression that “well, then it must be fine”. But the meaning is lost: in a couple of days 2 turns into a mystery. If we have already introduced a semantic type, it is logical to carry the idea through to the end and compare with StatusDone, StatusNew, and so on.
Mistake #4: mixing different “categories of numbers” in one variable.
For example, storing status, id, or “whatever came from input” in a variable status, because “it is all int anyway”. This almost always creates bugs that look random. User-defined types help keep things orderly: separately “what came in” (int), separately “the meaning in the program” (Status).
Mistake #5: choosing overly general names (type Code int).
If you name a type simply Code, it will quickly start meaning “everything in the world” and lose its usefulness. It is better to choose a name that reflects the meaning: Status, Perm, TaskState. Then the code is readable without translating from “numeric” to “human”.
GO TO FULL VERSION