25
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【TypeScript】「分かりそう」で「分からない」でも「分かった」気になれるインターフェース

Posted at

この記事について

本記事は公式TypeScript HandBook Interfacesをベースに記述しています。

インターフェース

TypeScript の中心的な原理の一つは、型チェックが値が持つ形状に焦点を当てるというものです。これは「ダックタイピング」や「構造的部分型」と呼ばれることもあります。TypeScript では、インターフェイスはこれらの型の命名の役割を果たし、コード内の契約やプロジェクト外のコードとの契約を定義するための強力な方法です。

まずはじめに

インターフェイスがどのように動作するかを理解する最も簡単な方法は、簡単な例から始めることです。

function printLabel(labeledObj: { label: string }) {
  console.log(labeledObj.label);
}

let myObj = { size: 10, label: "Size 10 Object" };
printLabel(myObj);

型チェッカーはprintLabelの呼び出しをチェックします。printLabel 関数のパラメータは 1 つで、渡されたオブジェクトが label という文字列型のプロパティを持っていることを要求します。このオブジェクトは実際にはこれよりも多くのプロパティを持っていますが、コンパイラは少なくとも必要なプロパティが存在し、必要な型と一致しているかどうかだけをチェックしていることに注意してください。TypeScriptがこれほど甘くない場合もありますが、それについてはまた後で説明します。

今回は、label プロパティが文字列であることの要件を記述するためにインターフェイスを使用しています。

interface LabeledValue {
  label: string;
}

function printLabel(labeledObj: LabeledValue) {
  console.log(labeledObj.label);
}

let myObj = { size: 10, label: "Size 10 Object" };
printLabel(myObj);

インタフェースLabeledValueは、先ほどの例の要件を説明するために使用できる名前です。これは、ラベルと呼ばれる文字列型の単一のプロパティを持っていることを表しています。他の言語のように、printLabelに渡すオブジェクトがこのインターフェイスを実装していることを明示的に言う必要はありません。ここでは、重要なのは形だけです。関数に渡すオブジェクトがリストにある条件を満たしていれば、それが許可されます。

型チェッカーは、これらのプロパティがどのような順番であるかを要求するのではなく、インターフェイスが要求するプロパティが存在し、要求された型を持っていることだけを指摘しておきましょう。

Optional Properties

インターフェースのすべてのプロパティが必要とされるわけではありません。ある条件の下で存在するものもあれば、全く存在しないものもあります。これらのオプションプロパティは、「オプションバッグ」のようなパターンを作成する際によく使われるもので、いくつかのプロパティを埋めただけのオブジェクトを関数に渡します。

このパターンの例を示します。

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  let newSquare = { color: "white", area: 100 };
  if (config.color) {
    newSquare.color = config.color;
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: "black" });

オプションのプロパティを持つインタフェースは他のインタフェースと同じように記述され、各オプションのプロパティは宣言のプロパティ名の最後に?を付けて表記される。

オプションのプロパティの利点は、利用可能な可能性のあるプロパティを記述することができる一方で、 インターフェースの一部ではないプロパティの使用を防ぐことができることです。例えば、createSquare で color プロパティの名前を間違えていた場合、そのことを知らせるエラーメッセージが表示されます。

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  let newSquare = { color: "white", area: 100 };
  if (config.clor) {
// Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?
    // Error: Property 'clor' does not exist on type 'SquareConfig'
    newSquare.color = config.clor;
// Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: "black" });

Readonly properties

いくつかのプロパティは、オブジェクトが最初に作成されたときにのみ変更できるようになっています。これを指定するには、プロパティの名前の前に readonly を付けます。

interface Point {
  readonly x: number;
  readonly y: number;
}

オブジェクトリテラルを代入することでPointを構築することができます。代入後、xとyは変更できません。

let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!
// Cannot assign to 'x' because it is a read-only property.

TypeScript には Array と同じ ReadonlyArray 型が付属していますが、これは Array と同じで、すべてのミューティングメソッドが削除されているため、作成後に配列を変更しないようにすることができます。

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;

ro[0] = 12; // error!
// Index signature in type 'readonly number[]' only permits reading.
ro.push(5); // error!
// Property 'push' does not exist on type 'readonly number[]'.
ro.length = 100; // error!
// Cannot assign to 'length' because it is a read-only property.
a = ro; // error!
// The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.

スニペットの最後の行で、ReadonlyArray全体を通常の配列に戻すことさえも違法であることがわかります。しかし、型アサーションでオーバーライドすることはできます。

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;

a = ro as number[];

readonly vs const

readonlyかconstかを覚える最も簡単な方法は、変数で使用しているのかプロパティで使用しているのかを聞くことです。変数は const を使用しますが、プロパティは readonly を使用します。

過剰なプロパティチェック

最初にインターフェイスを使った例では、TypeScriptでは{ size: number; label: string; }を{ label: string; }しか期待していなかったものに渡すことができます。また、オプションのプロパティと、いわゆる「オプションバッグ」を記述する際にそれらがどのように有用であるかについても学びました。

しかし、この2つを単純に組み合わせることで、こっそりとエラーが発生する可能性があります。例えば、前回の createSquare を使った例を見てみましょう。

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}

let mySquare = createSquare({ colour: "red", width: 100 });
// Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.
// Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?

createSquare に与えられた引数の綴りが color ではなく color になっていることに注目してください。プレーン・ジャバスクリプトでは、この種のことは黙って失敗します。

widthプロパティは互換性があり、colorプロパティは存在せず、余分なcolorプロパティは重要ではないので、このプログラムは正しく型付けされていると主張することができます。

しかし、TypeScriptはこのコードにバグがあるのではないかと考えています。オブジェクトリテラルは特別な扱いを受け、他の変数に代入したり、引数として渡したりする際に過剰なプロパティチェックを受けます。オブジェクトリテラルに "ターゲット型 "が持たないプロパティがある場合、エラーが発生します。

let mySquare = createSquare({ colour: "red", width: 100 });
//Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.
// Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?

これらのチェックを回避する方法は、実はとても簡単です。最も簡単な方法は、型アサーションを使うことです。

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

しかし、オブジェクトが特別な方法で使われるプロパティを持つことができると確信しているのであれば、文字列のインデックスシグネチャを追加するのが良い方法かもしれません。もし、SquareConfig が上記のような色と幅のプロパティを持っているだけでなく、他のプロパティをいくつでも持つことができるのであれば、次のように定義することができます。

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

インデックスシグネチャについては後ほど説明しますが、ここでは、SquareConfig はいくつでもプロパティを持つことができ、色や幅でなければ型は関係ありません。

これらのチェックを回避する最後の方法は、オブジェクトを別の変数に代入することです。squareOptionsは過剰なプロパティチェックを受けないので、コンパイラはエラーを出しません。

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

上記の回避策は、squareOptionsとSquareConfigの間に共通のプロパティがある限り、動作します。この例では、プロパティの幅を指定しています。しかし、変数に共通のオブジェクトプロパティがない場合は失敗します。例えば、以下のようになります。

let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions);
// Type '{ colour: string; }' has no properties in common with type 'SquareConfig'.

上記のような単純なコードでは、これらのチェックを「回避」しようとするべきではないことを覚えておいてください。メソッドを持ち、状態を保持するようなより複雑なオブジェクトリテラルの場合は、これらのテクニックを念頭に置いておく必要があるかもしれませんが、過剰なプロパティエラーの大部分は実際にはバグです。つまり、オプションバッグのようなもので過剰なプロパティチェックの問題に遭遇している場合は、型宣言のいくつかを修正する必要があるかもしれません。この例では、色や色の両方のプロパティを持つオブジェクトを createSquare に渡しても良いのであれば、それを反映させるために SquareConfig の定義を修正する必要があります。

Function Types

インターフェースは、JavaScriptのオブジェクトがとることのできる幅広い形を記述することができます。オブジェクトをプロパティで記述することに加えて、インターフェースは関数型を記述することもできます。

インターフェースで関数型を記述するには、インターフェースにコールシグネチャを与えます。これは、パラメータリストと戻り値の型だけが与えられた関数宣言のようなものです。パラメータリストの各パラメータは、名前と型の両方を必要とします。

interface SearchFunc {
  (source: string, subString: string): boolean;
}

一度定義してしまえば、この関数型インターフェースを他のインターフェースと同じように使うことができます。ここでは、関数型の変数を作成し、同じ型の関数値を代入する方法を示します。

let mySearch: SearchFunc;

mySearch = function (source: string, subString: string): boolean {
  let result = source.search(subString);
  return result > -1;
};

関数型が正しく型チェックを行うためには、パラメータの名前が一致する必要はありません。例えば、上の例ではこのように書くことができました。

let mySearch: SearchFunc;

mySearch = function (src: string, sub: string): boolean {
  let result = src.search(sub);
  return result > -1;
};

関数のパラメータは一度に一つずつチェックされ、それぞれの対応するパラメータ位置の型は互いにチェックされます。型を全く指定したくない場合は、関数の値がSearchFunc型の変数に直接代入されるので、TypeScriptのコンテキスト型付けで引数の型を推測することができます。ここでも、関数式の戻り値の型は、戻り値(ここでは false と true)によって暗示されています。

let mySearch: SearchFunc;

mySearch = function (src, sub) {
  let result = src.search(sub);
  return result > -1;
};

関数式が数値や文字列を返していた場合、型チェッカーは戻り値の型が SearchFunc インタフェースで記述されている戻り値の型と一致しないことを示すエラーを出していました。

let mySearch: SearchFunc;

mySearch = function (src, sub) {
// Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'.
// Type 'string' is not assignable to type 'boolean'.
  let result = src.search(sub);
  return "string";
};

Indexable Types(インデックス可能なタイプ)

インターフェースを使って関数型を記述する方法と同様に、a[10] や ageMap["daniel"] のように「インデックスを作成できる」型を記述することもできます。インデックス可能な型は、インデックスを作成する際に対応する戻り値の型とともに、オブジェクトにインデックスを作成する際に使用できる型を記述したインデックスシグネチャを持っています。例を見てみましょう。

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

上記では、インデックスシグネチャを持つStringArrayインターフェイスがあります。このインデックスシグネチャは、StringArrayが数値でインデックス付けされた場合、文字列を返すことを宣言しています。

サポートされているインデックスシグネチャには、文字列と数値の2つのタイプがあります。両方のタイプのインデクサをサポートすることは可能ですが、数値インデクサから返される型は、文字列インデクサから返される型のサブタイプでなければなりません。これは、数値でインデックスを作成する場合、JavaScriptはオブジェクトにインデックスを作成する前に、実際に文字列に変換するからです。つまり、100 (数字) でインデックスを作成することは、"100" (文字列) でインデックスを作成することと同じことなので、両者は一貫している必要があります。

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
  [x: number]: Animal;
// Numeric index type 'Animal' is not assignable to string index type 'Dog'.
  [x: string]: Dog;
}

文字列インデックスのシグネチャは、「辞書」パターンを記述する強力な方法ですが、すべてのプロパティがその戻り値の型と一致することを強制します。これは、文字列インデックスがobj.propertyもobj["property"]として利用可能であることを宣言しているからです。次の例では、name の型が文字列インデックスの型と一致せず、型チェッカーがエラーを出しています。

interface NumberDictionary {
  [index: string]: number;
  length: number; // ok, length is a number
  name: string; // error, the type of 'name' is not a subtype of the indexer
// Property 'name' of type 'string' is not assignable to string index type 'number'.
}

ただし、インデックス署名がプロパティタイプの和であれば、異なるタイプのプロパティも許容されます。

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length is a number
  name: string; // ok, name is a string
}

最後に、インデックスへの代入を防ぐために、インデックスシグネチャを読み取り専用にすることができます。

interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!
// Index signature in type 'ReadonlyStringArray' only permits reading.

インデックスのシグネチャは読み取り専用なので、myArray[2]を設定することはできません。

Class Types

インターフェースの実装

C#やJavaのような言語で最も一般的に使用されているインターフェースの1つである、クラスが特定の契約を満たすことを明示的に強制することは、TypeScriptでも可能です。

interface ClockInterface {
  currentTime: Date;
}

class Clock implements ClockInterface {
  currentTime: Date = new Date();
  constructor(h: number, m: number) {}
}

また、以下の例の setTime のように、クラスに実装されているインターフェイスのメソッドを記述することもできます。

interface ClockInterface {
  currentTime: Date;
  setTime(d: Date): void;
}

class Clock implements ClockInterface {
  currentTime: Date = new Date();
  setTime(d: Date) {
    this.currentTime = d;
  }
  constructor(h: number, m: number) {}
}

インターフェースは、パブリック側とプライベート側の両方ではなく、クラスのパブリック側を記述します。これは、クラスのインスタンスのプライベート側にも特定の型があることを確認するためにそれらを使用することを禁止しています。

クラスの静的側とインスタンス側の違い

クラスやインターフェイスを扱う際には、クラスには静的側の型とインスタンス側の型の2つの型があることを覚えておくと便利です。コンストラクトシグネチャを持つインターフェイスを作成し、このインターフェイスを実装したクラスを作成しようとするとエラーになることに気づくかもしれません。

interface ClockConstructor {
  new (hour: number, minute: number);
}

class Clock implements ClockConstructor {
// Class 'Clock' incorrectly implements interface 'ClockConstructor'.
// Type 'Clock' provides no match for the signature 'new (hour: number, minute: number): any'.
  currentTime: Date;
  constructor(h: number, m: number) {}
}

これは、クラスがインターフェイスを実装したときに、そのクラスのインスタンス側だけをチェックするからです。コンストラクタは静的側にあるので、このチェックには含まれません。

その代わりに、クラスのスタティックサイドを直接操作する必要があります。この例では、コンストラクタに ClockConstructor、インスタンスメソッドに ClockInterface という 2 つのインターフェイスを定義しています。そして、便宜上、コンストラクタ関数 createClock を定義し、これに渡された型のインスタンスを作成します。

interface ClockConstructor {
  new (hour: number, minute: number): ClockInterface;
}

interface ClockInterface {
  tick(): void;
}

function createClock(
  ctor: ClockConstructor,
  hour: number,
  minute: number
): ClockInterface {
  return new ctor(hour, minute);
}

class DigitalClock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log("beep beep");
  }
}

class AnalogClock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log("tick tock");
  }
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

createClockの最初のパラメータはClockConstructor型なので、createClock(AnalogClock, 7, 32)では、AnalogClockが正しいコンストラクタのシグネチャを持っているかどうかをチェックします。

もう一つの簡単な方法は、クラス式を使用することです。

interface ClockConstructor {
  new (hour: number, minute: number): ClockInterface;
}

interface ClockInterface {
  tick(): void;
}

const Clock: ClockConstructor = class Clock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log("beep beep");
  }
};

let clock = new Clock(12, 17);
clock.tick();

インターフェースの拡張

クラスと同様に、インターフェースは互いに拡張することができます。これにより、あるインターフェイスのメンバを別のインターフェイスにコピーすることができ、インターフェイスを再利用可能なコンポーネントに分離する方法をより柔軟にすることができます。

interface Shape {
  color: string;
}

interface Square extends Shape {
  sideLength: number;
}

let square = {} as Square;
square.color = "blue";
square.sideLength = 10;

インターフェースは、複数のインターフェースを拡張して、すべてのインターフェースの組み合わせを作成することができます。

interface Shape {
  color: string;
}

interface PenStroke {
  penWidth: number;
}

interface Square extends Shape, PenStroke {
  sideLength: number;
}

let square = {} as Square;
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

Hybrid Types

先に述べたように、インターフェイスは実世界のJavaScriptに存在する豊富な型を記述することができます。JavaScript は動的で柔軟性に富んでいるため、上記で説明した型のいくつかを組み合わせて動作するオブジェクトに遭遇することがあります。

そのような例として、関数とオブジェクトの両方として動作し、追加のプロパティを持つオブジェクトがあります。

interface Counter {
  (start: number): string;
  interval: number;
  reset(): void;
}

function getCounter(): Counter {
  let counter = function (start: number) {} as Counter;
  counter.interval = 123;
  counter.reset = function () {};
  return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

サードパーティのJavaScriptと対話する際には、上記のようなパターンを使用して型の形状を完全に記述する必要があるかもしれません。

クラスを拡張するインターフェース

インターフェース型がクラス型を拡張すると、そのクラスのメンバは継承されますが、その実装は継承されません。これは、あたかもインターフェイスが実装を提供せずにクラスのすべてのメンバを宣言しているかのようです。インターフェースは基底クラスのプライベートとプロテクトされたメンバーさえも継承します。つまり、プライベートやプロテクトされたメンバーを持つクラスを拡張するインターフェイスを作成した場合、そのインターフェイス型はそのクラスまたはそのサブクラスによってのみ実装することができます。

これは、大きな継承階層を持っているが、特定のプロパティを持つサブクラスのみで動作するようにコードを指定したい場合に便利です。サブクラスは、基底クラスから継承する以外に関連性がある必要はありません。例えば、以下のようになります。

class Control {
  private state: any;
}

interface SelectableControl extends Control {
  select(): void;
}

class Button extends Control implements SelectableControl {
  select() {}
}

class TextBox extends Control {
  select() {}
}

class ImageControl implements SelectableControl {
Class 'ImageControl' incorrectly implements interface 'SelectableControl'.
  Types have separate declarations of a private property 'state'.
  private state: any;
  select() {}
}

上記の例では、SelectableControl には、プライベートな state プロパティを含む Control のすべてのメンバが含まれています。state はプライベート・メンバなので、Control の子孫だけが SelectableControl を実装することができます。これは、Control の子孫のみが同じ宣言に由来する state プライベート・メンバを持つことになるためで、これはプライベート・メンバの互換性を確保するための要件です。

Controlクラス内では、SelectableControlのインスタンスを通して状態プライベート・メンバにアクセスすることが可能です。実質的に、SelectableControl は select メソッドを持つことが知られている Control のように動作します。Button と TextBox クラスは、SelectableControl のサブタイプです(どちらも Control を継承し、select メソッドを持っているため)。ImageControlクラスはControlを拡張するのではなく、それ自身の状態のプライベートメンバを持っているので、SelectableControlを実装することはできません。

参考

25
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?