🍵

TypeScriptの型の代入性についてわかりやすくまとめてみた

に公開

はじめに

はじめまして!都内でエンジニアをやっているケンタッキーです。
1年ほど前にプログラミングTypeScriptを読んだのですが、内容的に難しい部分が多々あり、自分の整理のためにもTypeScriptの型の代入性についてわかりやすくまとめようと思いこの記事を書きました。

概要

  • この記事では、TypeScriptの型の代入性についてTypeScriptの初学者でも理解しやすいように具体例を踏まえて解説しています。
  • この記事を読むことで、「型Aを型Bに割り当てることはできません。」というTypeScript頻出のエラーを理解し、自分で解決できるようになると思います。

この記事で伝えたいこと

  • TypeScriptの型の代入性はサブタイプかどうかによって決まる。
  • 余剰プロパティチェックはオブジェクトリテラルに対して実行される。

TypeScriptの型の扱い

TypeScriptの型の代入性について説明にする前に、JavaとTypeScriptの型の扱いの違いを比較しながら「TypeScriptの型の扱い」について見ていきます。

先に結論から言うと、

  • Javaは「名前的型付け(明示的に名前を指定する必要がある)」で型を扱い、
  • TypeScriptは「構造的型付け(構造的に同じ場合、同じ型とみなす)」で型を扱います。

具体例を用いて説明します。

TypeScriptの場合は、構造が同じかどうかで型を判断するので、以下のコードはエラーになりません。

typescriptの場合
type Person = {
    name: string;
    age: number;
}

type Employee = {
    name: string;
    age: number
    salary: number
}

function greet(person: Person) {
    console.log(`Hello, ${person.name}`)
}

const employee: Employee = {
    name: "Alice",
    age: 30,
    salary: 50000
}

greet(employee) // ✅ EmployeeはPersonの構造を満たすため、代入できる

それに対して、Javaは名前によって型を判断するため以下のコードはエラーになります。

Javaの場合
public class Person {
    String name;
    int age;
}

public class Employee {
    String name;
    int age;
    int salary;
}

void greet(Person person) {
    System.out.println("Hello, " + person.name);
}

Employee employee = new Employee();
// ❌ 「Employee cannot be converted to Person」といったエラーが発生
greet(employee);

このようにTypeScriptはJavaと違って型を内部の構造を見て判断しています。
そのため、ある型Aに構造が同じだが別の型Bを代入したりすることができます(型の代入性)
それでは、これからTypeScriptの非常に強力な型システムを支える「型の代入性」について解説していきます。

TypeScriptが構造的型付けを採用している理由

TypeScriptはJavaScriptのスーパーセットとなるプログラミング言語であるため、構造的型付けを採用している理由には、JavaScriptのダックタイピングオブジェクトリテラルが深く関係しています。

  • ダックタイピング
    • インタフェースをimplementsキーワードを使わず、メソッドの構造で判断すること
    • 「もし鳥がアヒルのように歩き、アヒルのように泣くなら、それはアヒルだ」という言葉が由来
  • オブジェクトリテラル
    • クラスやインタフェースの型を定義することなくその場でオブジェクトを生成する機能
      オブジェクトリテラルの例
      const circle = {
          radius: 10,
          area() {
              return Math.PI * this.radius ** 2;
          }
      }
      

JavaScriptは動的型付け言語であり、ダックタイピングを採用していた歴史があります。
また、オブジェクトリテラルによりcircleオブジェクトには型の名前がなく、型名から型を判断することができません。

-> 以上の理由により、TypeScriptは構造的型付けを採用したと考えられます

TypeScriptの型の代入性

TypeScriptの型の代入性とは、簡単に一言でまとめると「型Bは型Aのサブタイプ」ならば、「型Aを期待する箇所に型Bを割り当て可能」といえます。

サブタイプ = 以下の画像のように型Aに内包される型BというイメージでOKです。

TypeScriptの型の関係図を以下に示します。
上に位置するほど親のタイプ(スーパータイプ)となり、下に位置するほど、サブタイプとなります。

つまり、Unknown型Any型はほぼ全ての型のスーパータイプに当たり、Never型は全ての型のサブタイプに該当します。

Never型の例を見てみましょう。

型Bは型Aのサブタイプ」ならば、「型Aを期待する箇所に型Bを割り当て可能」といえます。

Never型は全ての型のサブタイプなので、どの型にも割り当てることができます。
逆にNever型のサブタイプは存在しないのでどの型も代入することができません。

never型の代入
// ❌ エラー: 型 '1' を型 'never' に割り当てることはできません。ts(2322)
const neverValue: never = 1

// ✅ エラーにならない
const numberValue: number = neverValue

Object型Array型の関係にも注目してみましょう。

Array型Object型のサブタイプなので、Object型を期待する箇所にArray型を代入可能です。

Object型とArray型
const arrayValue: Array<number> = [1, 2, 3]

// ✅ エラーにならない
const objectValue: Object = arrayValue

// ❌ エラー:逆はエラーになる
const arrayValue2: Array<number> = objectValue

図に記載はありませんが、プリミティブ型リテラル型についても考えてみましょう。

サブタイプの内包イメージがあれば、どちらがどちらに代入可能か想像つきそうですよね?

リテラル型プリミティブ型のサブタイプなので、プリミティブ型を期待する箇所には、リテラル型が代入可能となります。
(もちろんリテラル型を期待する箇所にプリミティブ型は代入不可能です)

プリミティブ型とリテラル型
// ✅ エラーにならない
const primitiveValue: number = 1

// ❌  エラー: 型 'number' を型 '1' に割り当てることはできません。ts(2322)
const literalValue: 1 = primitiveValue

ユニオン型についても触れておきます。

ユニオン型は、代入しようとしている型が期待するいずれかの型のサブタイプであれば、代入可能となります。

type Union = number | Array<number>

// ✅ 1(リテラル型)はnumber型のサブタイプなので、エラーにならない
const unionValue: Union = 1

Any型とUnknown型とNever型

TypeScriptの型の中でも少し特殊なAny型Unknown型Never型について解説します。

Any型

any型は、すべての型に代入可能な型であり、すべての型を代入可能な型という特徴を持つ、メタモンみたいな型です。any型を使ってしまうと、型安全性が一瞬で失われてしまうのでできるだけ使用は避けたいですね。

以下に例を示します。

any型
// ✅ エラーにならない
const anyValue: any = 1
// ✅ エラーにならない
const notAnyValue: number = anyValue

Unknown型

unknown型は、すべての型のスーパータイプに位置する王様のような(アルセウスみたいな)型ですが、any型より安全な型として利用できます。
なぜかというと、すべての型のスーパータイプなので、どんな値でも代入可能ですが、unknown型を他の型に代入しようとすると(TypeScriptの仕様的に)エラーになり、型ガードが必要になるからです。

unknown型
// ✅ エラーにならない
const unknownValue: unknown = 1

// ❌ エラー:型 'unknown' を型 'number' に割り当てることはできません。ts(2322)
const notUnknownValue: number = unknownValue

// ✅ 型ガードでエラーが解消される
if (typeof unknownValue === "number") {
  const numberValue: number = unknownValue
}

この型安全性がany型よりunknown型が好まれる理由です。

Never型

再掲になりますが、never型はすべての型のサブタイプです。そのため、どんな型にも代入することができますが、決してnever型に他の型を代入することができません。

never型
// ❌ 型 '1' を型 'never' に割り当てることはできません。
const neverValue: never = 1
// ✅ エラーにならない
const stringValue: string = neverValue

以上で特殊な型についての説明を終わります。
次は、鬼門であるオブジェクトのサブタイプについて解説していきます。

オブジェクトのサブタイプ

オブジェクトのサブタイプの定義は「オブジェクトAが、オブジェクトBの全ての必須プロパティのサブタイプを満たすプロパティを有している」ならば、「オブジェクトAはオブジェクトBのサブタイプとみなせるので、オブジェクトBを期待する箇所にオブジェクトAを代入可能」となります。

簡単な例で確認しましょう。
以下のコードはObj2Objの全ての必須プロパティにおいて、サブタイプを満たすプロパティを保持しているので、Objを期待する箇所にObj2を代入することが可能です。

オブジェクトのサブタイプ
type Obj = {
    name: string
    age: number
}

type Obj2 = {
    name: string
    age: number
    salary: number
}

const obj: Obj = { name: "John", age: 30}
const obj2: Obj2 = { name: "Ann", age: 20, salary: 4000}

const log = (o: Obj) => {
    console.log(o)
}

// ✅ エラーにならない
log(obj)
// ✅ エラーにならない
log(obj2)

では、逆の場合はどうでしょうか?

ObjObj2の全ての必須プロパティのサブタイプを満たすプロパティ保持していません。(salaryがない)
そのため、Obj2を期待する箇所にObjを代入するとエラーになります。

逆パターン
const log2 = (o: Obj2) => {
  console.log(o)
}

// ❌ エラーになる:型 'Obj' の引数を型 'Obj2' のパラメーターに割り当てることはできません。
//  プロパティ 'salary' は型 'Obj' にありませんが、型 'Obj2' では必須です。ts(234
log2(obj)

// ✅ エラーにならない
log2(obj2)

サブタイプでないとエラーになるパターンも確認しましょう。

以下のコードでは、Obj.age20 | 30リテラル型のユニオン型に変更しています。Obj2Objのすべてのプロパティのサブタイプを持つプロパティを有する必要がありますが、agenumber型であり、プリミティブ型のサブタイプではない(スーパータイプ)ので、今度はエラーになります。

サブタイプでないとエラーになるパターン
type Obj = {
  name: string
  age: 20 | 30 // リテラル型
}

type Obj2 = {
  name: string
  age: number
  salary: number
}

const obj: Obj = { name: "John", age: 30}
const obj2: Obj2 = { name: "Ann", age: 20, salary: 4000}

const log = (o: Obj) => {
  console.log(o)
}

// ✅ エラーにならない
log(obj)

// ❌ エラーになる:型 'Obj2' の引数を型 'Obj' のパラメーターに割り当てることはできません。
//  プロパティ 'age' の型に互換性がありません。
//  型 'number' を型 '20 | 30' に割り当てることはできません。
log(obj2)

最後にオプショナルなプロパティのケースについても確認しておきましょう

例えば、Obj2salaryがオプショナルになった場合objは代入可能になるでしょうか?
答えは代入可能になります。
salarynumber | undefinedの型になり、
undefinedは定義がないという意味なので、Objになくてもサブタイプを満たすからです

オプショナルなプロパティ
type Obj = {
  name: string
  age: number
}

type Obj2 = {
  name: string
  age: number
  salary?: number
}

const obj: Obj = { name: "John", age: 30}
const obj2: Obj2 = { name: "Ann", age: 20, salary: 4000}

const log2 = (o: Obj2) => {
  console.log(o)
}
// ✅ エラーにならない
log2(obj)
// ✅ エラーにならない
log2(obj2)

これでオブジェクトのサブタイプは「オブジェクトAが、オブジェクトBの全ての必須プロパティのサブタイプを満たすプロパティを有している」というのがイメージを掴むことができたのではないでしょうか?

次にオブジェクトの鬼門でもある、余剰プロパティチェックについて解説します。

余剰プロパティチェック

先ほどの説明では、オブジェクトAが、オブジェクトBの全プロパティのサブタイプを有しているならば、その時点でサブタイプとみなすことができ、代入できると説明しました。
しかし、実はオブジェクトAがオブジェクトBにはないプロパティ(余剰なプロパティ)を持っていたらエラーになるケースがあります。

それは、オブジェクトリテラルを代入しようとした時です。

✍️ オブジェクトリテラルとは{}で囲まれた直接なオブジェクトです。

// これはオブジェクトリテラル
const person = {
    name: "Alice",
    age: 30
}

// これはオブジェクトリテラルでない
const person2 = person

オブジェクトリテラルを代入するときに、余剰プロパティチェックというものが走り、不要なプロパティを持っているとエラーになるのです。

実際にコードで確認してみましょう。

先ほどの例でobjにはオブジェクトリテラルを代入していましたが、新しくsalaryというプロパティを追加すると、エラーが発生します。このエラーが余剰プロパティチェックです。これは厳密にいうとサブタイプとはまた別の概念ですが、TypeScriptを扱う上でとても重要な概念です。

余剰プロパティチェックの例
type Obj = {
  name: string
  age: 20 | 30
}

type Obj2 = {
  name: string
  age: number
  salary: number
}

// ❌ エラーになる:オブジェクト リテラルは既知のプロパティのみ指定できます。'salary' は型 'Obj' に存在しません。ts(2353)
const obj: Obj = { name: "John", age: 30, salary: 1000}
const obj2: Obj2 = { name: "Ann", age: 20, salary: 4000}

余剰プロパティチェックの具体例をもう少し見ていきましょう。
以下の例はオブジェクトリテラルを直接代入しているので余剰プロパティチェックが走ります。

余剰プロパティチェックが走る例
type Option = {
   baseUrl: string
  cacheSize: number
}

class API {
  constructor(option: Option){}
}

// ❌ エラー: オブジェクト リテラルは既知のプロパティのみ指定できます。'timeout' は型 'Option' に存在しません。ts(2353)
new API({
  baseUrl: "https://api.example.com",
  cacheSize: 1000,
  timeout: 1000,
})

次はasで型アサーションをした例です。
型アサーションを利用すると、オブジェクトリテラルを代入しても余剰プロパティチェックは実行されません。

余剰プロパティチェックが走らない例
// ✅ エラーにならない
new API({
  baseUrl: "https://api.example.com",
  cacheSize: 1000,
  timeout: 1000,
} as Option)

次は変数に代入した例です。変数に代入することでオブジェクトリテラルでなくなる(フレッシュなオブジェクトになる)ので、余剰プロパティチェックは実行されません。

余剰プロパティチェックが走らない例
const option = {
  baseUrl: "https://api.example.com",
  cacheSize: 1000,
  timeout: 1000,
}

// ✅ エラーにならない
new API(option)

最後に、変数に型を定義した場合です。これはOption型にオブジェクトリテラルを代入する時点で余剰プロパティチェックが走るのでエラーになります。

余剰プロパティチェックが走る例
// ❌ エラー:オブジェクト リテラルは既知のプロパティのみ指定できます。'timeout' は型 'Option' に存在しません。ts(2353)
const option2: Option = {
  baseUrl: "https://api.example.com",
  cacheSize: 1000,
  timeout: 1000,
}

以上で余剰プロパティチェックの説明を終了します。
最後に、関数型の型の代入性について説明します。

関数型

関数型は以下の条件を満たした場合、サブタイプと見なすことができます。

  1. 引数の数:代入先が代入元より多い引数
  2. 引数の型:代入先の引数型が代入元の引数型のスーパータイプ
  3. 戻り値の型:代入先の戻り値型が代入元の戻り値型のサブタイプ

つまり、上記の3つの条件を満たしていないとサブタイプと判断されないので型の代入が行えません。

言葉より具体例を見た方が理解しやすいと思います。
まずは引数の数が異なる関数の例から示します。

  1. 引数の数:代入先が代入元より多い引数

以下のコードでは、Func1の型にFunc2の型を代入しようとする際に、引数の数が、Func2の方がFunc1より多いためエラーになります。
逆に、Func2の型にFunc1の型を代入する場合は、Func1Func2より少ない引数を持つため、余分な引数は無視されエラーになりません。

引数の数が異なる例
type Func1 = (x: number) => number;
type Func2 = (x: number, y: number) => number;

const func1: Func1 = (x) => x * 2;
const func2: Func2 = (x, y) => x + y;

// ❌ エラー:型 'Func2' を型 'Func1' に割り当てることはできません。ターゲット署名の引数が少なすぎます。2 以上が必要ですが、1 でした。
const func3: Func1 = func2;
// ✅ エラーにならない
const func4: Func2 = func1;

次に引数の型がスーパータイプとなる例を示します。

  1. 引数の型:代入先の引数型が代入元の引数型のスーパータイプ

以下のコードでは、Func1Func2の引数の数は同じですが、Func1xの型(1 | 10 | 100)がFunc2xの型(number)のサブタイプとなっています。

つまり、Func1を期待する箇所にFunc2を代入可能ですが、その逆は代入不可能です。

引数がスーパータイプになる例
type Func1 = (x: 1 | 10| 100, y: number) => number;
type Func2 = (x: number, y: number) => number;

const func1: Func1 = (x, y) => x * y;
const func2: Func2 = (x, y) => x * y;

// ✅ エラーにならない
const func3: Func1 = func2

// ❌ エラー:型 'Func1' を型 'Func2' に割り当てることはできません。パラメーター 'x' および 'x' は型に互換性がありません。型 'number' を型 '1 | 10 | 100' に割り当てることはできません。ts(2322)
const func4: Func2 = func1

最後に戻り値がサブタイプとなる例を示します。

  1. 戻り値の型:代入先の戻り値型が代入元の戻り値型のサブタイプ

Func1の戻り値型(number)はFunc2の戻り値型(number | string)のサブタイプです。
そのため、Func2を期待する箇所にFunc1は代入可能ですが、その逆はエラーになります。

戻り値がサブタイプ
type Func1 = (x: number, y: number) => number;
type Func2 = (x: number, y: number) => number | string;

const func1: Func1 = (x, y) => x * y;
const func2: Func2 = (x, y) => x * y;


// ❌ エラー:型 'Func2' を型 'Func1' に割り当てることはできません。
// 型 'string | number' を型 'number' に割り当てることはできません。
// 型 'string' を型 'number' に割り当てることはできません。ts(2322)
const func3: Func1 = func2

// ✅ エラーにならない
const func4: Func2 = func1

おわりに

近頃は生成AIが発達して簡単なエラーであればすぐ解決してくれますが、だからこそ、エンジニアの理解力や認知力で差が出ると個人的に考えているため、このタイミングで、TypeScriptの基本について自分の整理のためにもまとめてみました。
あまり記事を書くのは得意ではないですが、次は「TypeScriptの型推論」や「現場でよく使うTips」、「型の構築」なんかについて記事を書こうかなと思っているのでお楽しみに✨

Discussion