TypeScriptユーザーから見るGoの型
ユニオン型について
TypeScriptのユニオン型
TypeScriptのユニオン型とは、複数の型のいずれかを受け取る型のことです。
let value: string | number;
value = "hello"; // OK(string)
value = 42; // OK(number)
value = true; // エラー(booleanは含まれていない)
また、TypeScriptのユニオン型は自由度が高く、リテラル型と組み合わせて使用することができます。
type Status = "success" | "error" | "loading";
function handleResponse(status: Status) {
switch (status) {
case "success":
console.log("Operation successful!");
break;
case "error":
console.log("Something went wrong.");
break;
case "loading":
console.log("Loading...");
break;
}
}
handleResponse("success"); // "Operation successful!"
個人的にはサーバー側でenum
として定義されてるレスポンス項目を表現するときによくユニオン型を使用します。とっても便利です。
Goのユニオン型
Goではinterface
の型セットとしてユニオンを定義することができます。
In their most general form, an interface element may also be an arbitrary type term T, or a term of the form ~T specifying the underlying type T, or a union of terms t1|t2|…|tn [Go 1.18]. Together with method specifications, these elements enable the precise definition of an interface's type set as follows:
ユニオンで定義したinterface
は、ジェネリック型を制約する役割として使用します。
type Num interface {
int | uint
}
func Sum[T Num](a T, b T) T {
return a + b
}
var sum = Sum(1, uint(2)) // sum = 3
var sumError = Sum(1, int64(2)) // int64 does not satisfy Num (int64 missing in int | uint)
上記の例ではint
もしくはuint
のみ受け入れるNum
インターフェースを定義しています。
Sum
関数は、ジェネリック型T
をNum
に制約し、許可された型のみを受け入れます。
Sum(1, uint(2))
はコンパイル可能ですが、Sum(1, int64(2))
はint64
がNum
に含まれないためコンパイルエラーとなります。
なお、構造体をユニオンに含めることもできます。
type Child struct {
Name string
Age int
}
type Parent struct {
Name string
Age int
Address string
}
type Family interface {
Child | Parent
}
type Parson[T Family] struct {
Result T
}
var person = Parson[Child]{Result: Child{Name: "Taro", Age: 10}}
type Father struct {
Name string
}
var parsonError = Parson[Father]{Result: Father{Name: "Taro"}}
// Father does not satisfy Family (Father missing in Child | Parent)
ただし、値や変数の型として使用したり、他の非インターフェース型の構成要素として使用することはできません。
type Num interface {
int | uint
}
var x Num // illegal: Num is not a basic interface
var x interface{} = Num(nil) // illegal
type Number struct {
f Num // illegal
}
また、リテラルと組み合わせることもできません。
type Num interface {
1 | "2" // illegal
}
このようにGoでもユニオン型は提供されているものの、TypeScriptと比べると多くの制限があるように感じます。
またさらっとジェネリックを取り扱いましたが、Goのジェネリックをよく理解したい方は以下の記事をご覧ください。とてもわかりやすいです。
数値型について
TypeScriptの数値型
TypeScriptの数値型は基本的にnumber
で定義します。
このnumber
型は整数と小数を合わせた型となっております。
正確にはIEEE 754の倍精度浮動小数点数(64ビット)であり、52ビットが仮数部、11ビットが指数部として使われます。
const num:number = 1 // ok
const num2:number = 0.1 // ok
またTypeScriptではnumber
の他にbigint
というより大きな整数を扱うための型も存在します。
bigint
型を扱うには整数の後にn
を書く必要があります。
const bigint = 100n
ただし、このbigint
は古いブラウザでは使用することができないなどの制限があるため、見かける機会は少ないかもしれません。
なお、number
とbigint
同士の計算はできません。
const num: number = 100;
const bigint: bigint = 100n;
const sum = num + bigint; // 演算子 '+' を型 '100' および 'bigint' に適用することはできません。
Goの数値型
Goの数値型は整数型・浮動小数点数型・複素数型の3つに分類されます。
整数型
整数型では正の整数のみ表すことができる符号なし整数型(uint
)と負の整数も表すことができる符号あり整数型(int
)に分類されます。
それぞれの整数型は環境依存(32/64bit)のサイズと8,16,32,64ビットの固定サイズで表されます。
これにuintptr
というポインタ値を格納するための符号なし整数型を加えると、整数型だけで11種類も存在します。
型名 | ビット数 |
---|---|
int | 環境依存(32/64bit) |
int8 | 8-bit |
int16 | 16-bit |
int32 | 32-bit |
int64 | 64-bit |
uint | 環境依存(32/64bit) |
uint8 | 8-bit |
uint16 | 16-bit |
uint32 | 32-bit |
uint64 | 64-bit |
uintptr | 環境依存(32/64bit) |
整数を型指定せずに変数へ代入すると、デフォルトでint
が推論されます。
var num = 1 // var num int
浮動小数点数型
Goの浮動小数点数型はfloat32
とfloat64
の2種類です。
また、TypeScriptのnumber
型と同様、IEEE 754に基づいて演算を行うよう設計されています。
Float operations produce the same results as the corresponding float32 or float64 IEEE 754 arithmetic for operands
つまり、Goのfloat64
はTypeScriptのnumber
に最も近い型だといえます。
小数を型指定せずに変数へ代入すると、デフォルトでfloat64
が推論されます。
var float = 0.1 // var float float64
複素数型
まず、実数と虚数を組み合わせた数で、一般的には「a+bi」の形で表されます。
Goで複素数を実数+虚数の構文で表現し、複素数を表現する型としてcomplex64
とcomplex128
を提供しています。
複素数を型指定せずに変数へ代入すると、デフォルトでcomplex128
が推論されます。
var com = 1 + 2i // var com complex128
型システムについて
構造的型付けと名前的型付け
構造的型付けとは型の構造(プロパティやメソッド)が一致すれば同じ型とみなすことを指します。
type Person = { name: string };
type User = { name: string };
const p: Person = { name: "Alice" };
const u: User = p; // OK!
Person
型と User
型は、どちらも name: string
という同じプロパティを持つため、構造的には同じ型とみなされます。
そのため、Person
型の変数 p
を User
型の変数 u
に代入してもエラーは発生しません。
一方で異なる構造を持つ場合、型の不一致によってエラーが発生します。
type Person = { age: number };
type User = { name: string };
const p: Person = { age: 20 };
const u: User = p; // プロパティ 'name' は型 'Person' にありませんが、型 'User' では必須です。
TypeScriptはこの構造的型付けを採用しています。
また、名前的型付けという型の判定方法もあります。
これは文字通り、型の名前が一致しないと同じ型とみなされない方式になります。
以下はJavaの例です。
class Person {
String name;
Person(String name) {
this.name = name;
}
}
class User {
String name;
User(String name) {
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
Person p = new Person("Alice");
User u = p; // error
}
}
Person
クラスとUser
クラスは、どちらもname: String
という同じフィールドを持っています。
しかし、Javaは名前的型付けを採用しているため、Person
型のインスタンスを User
型の変数に代入することはできません。
型の名前 (Person
とUser
) が異なる場合、たとえ構造が同じであっても、それらは別の型として扱われます。
そのため、型の互換性がないと判断されコンパイルエラーが発生します。
部分型について
型同士の関係は階層構造として捉えることができます。
最も抽象的な型が階層の頂点にあり、下に進むにつれてより具体的な型へと分岐していきます。
上位にある型は基本型と呼ばれ、より汎用的な性質を持ちます。
一方、下位に位置する型は部分型と呼ばれ、基本型をもとにより具体的な特性を持つ型として定義されます。
この部分型は主に名前的部分型と構造的部分型の2種類にわかれます。
名前的部分型
名前的型付けを採用している多くの言語は名前的部分型です。
名前的部分型はクラスや型の互換性を判断する際に、型の名前と継承関係を重視します。
例えばJavaでは、extends
を使用して基本型と部分型の関係を宣言します。
class Shape {}
class Circle extends Shape {}
Shape shape = new Circle(); // OK!
もし継承関係が宣言されていない場合、両者の間に階層関係は存在しません。
そのため、Shape
型で宣言した変数にCircle
のインスタンスを渡すとコンパイルエラーとなります。
class Shape {}
class Circle {}
Shape shape = new Circle(); // error
構造的部分型
これに対して、構造的部分型は型が持つプロパティやメソッドの構造に基づいて型の互換性を判断します。
ちなみにTypeScriptはこの構造的部分型を採用しております。
class Animal {
bark(): string {
return '';
}
}
class Dog {
sound: string;
constructor(sound: string) {
this.sound = sound;
}
bark(): string {
return this.sound;
}
}
function letItBark(animal: Animal) {
return animal.bark();
}
const dog = new Dog('Wan!');
letItBark(dog); // dogはspeakメソッドをもっているからOK!
letItBark
関数はAnimal
型を引数に取りますが、Dog
クラスはAnimal
クラスを継承していません。
しかし、Dog
クラスは bark
メソッドを持っているため、Animal
型の構造を満たしています。
これによりDog
クラスのインスタンスを letItBark
へ渡しても問題なく動作します。
Goは名前的型付け?構造的型付け?
Goは構造的型付けだと言われることもありますが、実際には名前的型付けのような挙動をします。
例えば、以下のコードでは name
プロパティを持つUser
とPerson
という構造体を定義しています。
一見、構造が同じなので代入できそうですが、Goでは異なる型として扱われるためコンパイルエラーになります。
type User struct {
Name string
}
type Person struct {
Name string
}
var user = User{Name: "hoge"}
var person Person = user // cannot use user (variable of type User) as Person value in variable declaration
このように、Goの構造体は明示的に型が一致しない限り互換性がないため、異なる構造体間で代入を行うには型変換が必要です。
一方、interface に関しては構造的部分型が適用されています。
type Animal interface {
Bark() string
}
type Dog struct {
Sound string
}
func (d Dog) Bark() string {
return d.Sound
}
// letItBark関数はAnimalインターフェースを受け取り、Barkメソッドを呼び出す
func letItBark(animal Animal) string {
return animal.Bark()
}
func main() {
dog := Dog{Sound: "Wan!"}
letItBark(dog) // 構造的部分型なのでOK!
}
Animal
インターフェースは Bark
メソッドを持つ型であれば受け入れます。
Dog
構造体はBark
メソッドを実装しており、明示的な継承なしでAnimal
インターフェースを満たします。
よってinterface
に関して言えば、TypeScriptと同様に構造的部分型であると言えます。
Goが名前的型付けなのかそれとも構造的型付けなのかについては意見がわかれるようです。
サバイバルTypeScriptではGoは構造的型付けであると述べられています。
しかしながら、名前的型付けにinterface
のみ構造的型付けを採用したと解釈する方もいらっしゃるみたいです。
ちなみに構造的型付けシステムでは異なる目的や意味を持つ型であっても、たまたま同じ構造を持っていると、意図せずに互換性があると見なされることがあります。
type User = {
name: string;
age: number;
};
type Person = {
name: string;
};
const user: User = {
name: 'hoge',
age: 20,
};
const person: Person = user; // not compile error!!
console.log(person); // { name: 'hoge' , age: 20 }が出力されます。。
上記のUser
型はPerson
型が要求するname
プロパティを含んでいるため、代入が可能です。
さらにconsole.log(person)
で確認すると、Person
型には存在しないage
プロパティも表示されてしまうため、TypeScriptでは意図しないプロパティの混入に気を付ける必要があります。
しかしながらGoの構造体は名前的型付けなので、このようなトラブルに注意を払う必要はありません。
名前的型付けと構造的型付けのサラブレットって感じですね。
変数の初期値について
TypeScriptでは明示的に初期値を設定しないと未定義(undefined
)となります。
ちなみにconst
では初期値を省略することはできません。
let str: string; // undefined
let num: number; // undefined
let bool: boolean; // undefined
しかしながら、Goでは型ごとにゼロ値が割り振られています。
このゼロ値により、明示的に初期値を設定しなくても未定義にはなりません。var str string // ""
var num int // 0
var bl bool // false
構造体のフィールドが初期化されていない場合でも、そのフィールドはゼロ値として自動的に初期化されます。
type User = {
name: string;
age: number;
};
const user: User = { name: 'hoge' }; // {hoge 0}
この場合、User
型に age
プロパティが指定されていなくても、コンパイルエラーは発生せず、age
は 0 として扱われます。
一方、TypeScriptではオプショナルを明記しなければ、コンパイルエラーとなります。
type User = {
name: string;
age: number;
};
const user:User = {name:"hoge"} // プロパティ 'age' は型 '{ name: string; }' にありませんが、型 'User' では必須です。ts(2741)
以前はこの仕様を理解していなかったため、「なんでコンパイルエラーにならないんだ!」と、だいぶ戸惑ってしまいました。
いまだに違和感を覚えますが、慣れていくしかないですかね。
おまけ
つい先日、興味深い発見をしたので共有しておきます(既に広く認知されていることでしたらすみません)。
きっかけは数日前にpostされたとあるXのつぶやきでした。
そのつぶやきの内容はTypeScriptで以下のソースコードが通らないのはおかしいというものでした。
const a: null | { payload: number } = null;
console.log(a?.payload); // プロパティ 'payload' は型 'never' に存在しません。
さらに主さんは「never
型はあらゆる型の部分型だからプロパティアクセスを許可してほしい」と主張しましたが、私はこの意味をあまり理解することができなかったため少し調査を行うことにしました。
その過程で以下のコードがどのような挙動をするか試しました。
type User = {
name: string;
age: number;
};
const user: User = { name: 'hoge', age:"永遠の18歳" as never };
このコードではUser
の{ age: number }
にnever
でキャストした文字列を代入しています。
もちろんnever
型はあらゆる型の部分型なのでコンパイルエラーは起きません。
それでは"永遠の18歳" as never
を代入したage
プロパティをconsole.log
で出力してみます。
恐らくnever
型にプロパティアクセスするので、コンパイルエラーになるでしょう。
あれ?なぜか「永遠の18歳」が出力されました。次は型を確認してみます。
なるほど、部分型にキャストしても元の型は変わらないし、代入自体はできているからプロパティアクセスも可能ということなのでしょう。
では、元の型を指定せずにキャストしたらどうなるのでしょうか。
const user2 = {age:"永遠の20歳" as never}
さきほどと同様にコンパイルエラーは発生しません。
それではconsole.log
で出力してみます。
なるほど、では型はというと、、
え????never
型になるの??
never
型でプロパティアクセスできちゃってるけどいいんですかね?
正直なぜこのような挙動になるか理解できないので、詳しい方はぜひ教えてください!
参考
Discussion
bigint の実装が一番新しいのは Safari 14 (2020/09/16 release)
iOS のバージョンは Safari のバージョンと合わせているので
14.0.1 の最後のリリースが 2021/10/26 で サポート自体も 3年前 に切れている感じですね。
(※現最新は 18.3.2 (2025/03/11 release)
現状はほとんどのブラウザで対応できるということですね!
ありがとうございます!