imu-note
いむたろ
いむたろ
@imutaroh
新卒エンジニア / データ基盤 × AI

定義型:それ用の型を作る

type X string で、型できる。

type TableID string と書くと、TableID という新しい型ができる。中身のメモリは string と同じ。それでも string とは別の型。代入できるかどうかは、メモリの形ではなく型の名前で決まる。

type DatasetID string
type TableID string

func loadTable(ds DatasetID, tb TableID) string {
	return fmt.Sprintf("%s.%s", ds, tb)
}

ds := DatasetID("raw")
tb := TableID("orders")
loadTable(ds, tb) // raw.orders
loadTable(tb, ds) // コンパイルエラー:cannot use tb (variable of string type TableID) as DatasetID value

引数がどちらも string だと、逆に渡してもコンパイルが通る。 raw.orders に書くつもりで orders.raw に書いてしまい、気づくのは本番。型を分けておけば、ビルドの時点で止まる。

書き方通るか理由
var t TableID = s(s は string の変数)通らない型が違う
var t TableID = TableID(s)通る自分で型を変換している
var t TableID = "orders"通る"orders" は型の付いていない定数。代入先の型に合わせて変わる

止まるのは型の付いた変数を取り違えたときだけ。リテラルは素通りするので、loadTable("orders", "raw") はコンパイルが通る。

うれしいこと3つ

入れ違いなし、短くなる、X だけのメソッドを作れる

  1. 入れ違いを止める:同じ string でも、DatasetID と TableID は混ざらない
  2. 長い型に短い名前:struct{ Name string; Age int } を毎回書かずに User で済む
  3. それ用のメソッド:その型の値にだけ付くメソッドを足せる(次の節)

= を付けるとエイリアス

= を付けたらエイリアスになるだけ。名前は違うけど、両方 string。

type TableID string      // 定義型。string とは別の型
type ColumnName = string // エイリアス。string に別名を付けただけ

var s string = "x"
var c ColumnName = s // 通る。同じ型なので

型名と同じ名前の変数

関数の中で var int int = 1 と書くと、コンパイルは通る。int は if や func のような予約語ではなく、Go があらかじめ用意している名前(事前宣言された識別子)だから。ただし、その後のスコープでは int が変数を指すので、型としての int が使えなくなる(int (local variable) is not a type)。len error url も同じ。

型は値として扱えない

Go の型は一級市民ではない。t := int のように、型そのものを変数に入れることはできない。型を値として扱いたいときは reflect.TypeOf(値) を使う。ライブラリを作る側の道具なので、今は名前だけ。

組み込み型にメソッドを足す

影響なし、他パッケージは無理/操作は引き継ぐ/検査のため

type Minutes int の int のように、材料になった型を基底型という。定義型には、int や string が元でもメソッドを足せる。

type Minutes int

//   ↓レシーバー:Minutes の値に付く。中では m と呼ぶ
func (m Minutes) Hours() float64 {
	return float64(m) / 60
}

var m Minutes = 90
m.Hours() // 1.5
  • レシーバーは、他の言語の this や self を自分で名前を付けて受け取るもの。型の頭文字1文字が慣習
  • 足したメソッドは Minutes にだけ付く。ほかの int には何も起きない
n := 90            // int
n.Hours()          // コンパイルエラー:type int has no field or method Hours
Minutes(n).Hours() // 1.5

Minutes(n) は関数の呼び出しではなく型変換。見た目は同じ。カッコの前が type で作った名前なら変換、func で作った名前なら呼び出し。

同じ名前のメソッドでも、呼ばれるのは値の型に付いているほう。

type Seconds int
func (s Seconds) Hours() float64 { return float64(s) / 3600 }

var x Minutes = 90
var y Seconds = 90
fmt.Println(x.Hours(), y.Hours()) // 1.5 0.025

よそのパッケージの型(time.Duration など)には、メソッドを直接足せない。足したいなら自分のパッケージで型を作る。

基底型から引き継ぐもの・引き継がないもの

引き継ぐか例
基底型の操作引き継ぐMinutes 同士の足し算、マップが元なら r["id"]・len・range
型の区別残る(混ぜられない)Minutes と int の変数は足せない(mismatched types Minutes and int)
元の型のメソッド引き継がないtype MyDuration time.Duration には .Hours() が付いてこない
type Row map[string]string

func (r Row) ID() string { return r["id"] }

r := Row{}    // マップと同じで初期化が要る。var r Row のままだと書き込みで panic
r["id"] = "1" // マップの操作がそのまま使える
len(r)        // 1
r.ID()        // "1"

メソッドで全部包むか、基底型の操作も使わせるかは設計で決める。

ファクトリー関数:作り方の入口に検査を置く

型変換は値を検査しない。BatchSize(-3) は -3 のまま通る。不正な値を防ぎたいなら、検査を入れた New で始まる関数を用意する。

type BatchSize int

// 検査は型のメソッドに置く
func (b BatchSize) Validate() error {
	if b < 1 || b > 1000 {
		return fmt.Errorf("batch size must be 1..1000: %d", int(b))
	}
	return nil
}

func NewBatchSize(n int) (BatchSize, error) {
	b := BatchSize(n)
	err := b.Validate()
	if err != nil {
		return 0, err
	}
	return b, nil
}

NewBatchSize(-3)  // 0 batch size must be 1..1000: -3
NewBatchSize(500) // 500 <nil>
  • 検査を通った値しか返らない
  • ほかの人が「この型は NewBatchSize で作る」とわかる

処理に名前を付けて、型のメソッドにする

d := "2026-10-23"
year := d[0:4] // 何を取り出しているのか、読んでもすぐにはわからない
type Date string

func (d Date) Year() string {
	return string(d[0:4]) // 取り出し方はここに1回だけ書く
}

year := d.Year() // 読めばわかる

呼び出し元に散らばっている処理を、その値の型のメソッドに寄せる。寄せると、呼び出し元は読んでわかる言葉だけになり、寄せたメソッドは1つずつテストできる。形は4つあるが、やっていることは全部これ。

形寄せる前寄せた後
スライス呼び出し元で毎回 for と ifrows.FailedToLoad()
値呼び出し元で d[0:4] と切り出すd.Year()
列挙呼び出し元で if plan == PlanProplan.MonthlyFee()
構造体IsAdult(customer) のように構造体を引数に取る関数customer.IsAdult()

スライス

type Record struct {
	Table string
	Err   string
}

type Records []Record

func (rs Records) FailedToLoad() Records {
	out := make([]Record, 0, len(rs))
	for _, r := range rs {
		if r.Err != "" {
			out = append(out, r)
		}
	}
	return out
}

// InTable(t string) も同じ形で、条件を r.Table == t にしたもの
rows.InTable("orders").FailedToLoad()

つなげて書けるのは、戻り値を Records にそろえたから。レシーバーが同じ型だからではない。戻り値が []Record だと、そこで .FailedToLoad() が付かなくなって切れる。

取ってきたデータにループを回す処理が多いなら、コードを見直すきっかけ。

列挙

type SubscriptionPlan int

const (
	PlanFree SubscriptionPlan = iota + 1 // 1
	PlanPro                              // 2
	PlanTeam                             // 3
)

func (p SubscriptionPlan) MonthlyFee() int {
	switch p {
	case PlanPro:
		return 1000
	case PlanTeam:
		return 3000
	}
	return 0
}
  • iota は const ( ) の中で 0, 1, 2… と自動で数を振る。2行目以降は型と = iota を省略できる
  • iota + 1 で1から始めると、何も入れていない変数(ゼロ値の 0)がどのプランとも一致しない。「設定し忘れ」と PlanFree を区別できる
  • プランが増えても、直すのは MonthlyFee() の中だけ
  • 定数名の頭に型の名前(Plan)を付けると、PlanPro だけで何の列挙かわかる

構造体

func IsAdult(c Customer) bool { return c.Age >= 18 }     // 関数のまま
func (c Customer) IsAdult() bool { return c.Age >= 18 } // メソッド

中身は同じ。Customer をカッコの中に書くか、関数名の前に書くかだけ。関数の中で引数の構造体のフィールドばかり触っていたら、メソッドにする候補。

構造体が2つ出てくるときは、その処理が誰のことを聞いているかで分ける。

// 「この客は何割引きか」は Customer のこと
func (c Customer) MemberDiscountRate() float64 {
	if c.Premium {
		return 0.1
	}
	return 0
}

// 「割引を反映した合計」は Order のこと
func (o Order) TotalAfterDiscount(rate float64) int {
	return int(float64(o.Amount) * (1 - rate))
}

total := order.TotalAfterDiscount(customer.MemberDiscountRate())

Order は Customer の中身を知らずに済む。どちらのことでもない処理(注文を確定してメールを送って在庫を減らす、のような段取り)は、無理にメソッドにしない。

関連