1. ホーム
  2. TypeScript

【TypeScript】呼び出しシグネチャ・コンストラクトシグネチャの使い方|interface で関数やクラスの型を定義する

Share

TypeScript で関数の型を書くとき、多くの場合は type Fn = (x: number) => string というアロー記法を使います。ところが型定義ファイルや少し込み入ったコードを読むと、interface の中に (x: number): string と書かれていたり、new (name: string): Person という見慣れない行が出てきたりします。これが呼び出しシグネチャコンストラクトシグネチャです。この記事では、アロー記法との違い、呼び出しシグネチャでしか表せないこと(プロパティを持つ関数、オーバーロード)、クラスそのものを引数で受け取るコンストラクトシグネチャ、そして混乱しやすい :=> の書き分けまでを順に整理します。

関数の型には2つの書き方がある

「数値を1つ受け取って文字列を返す関数」という型は、TypeScript では2通りに書けます。1つは矢印で書く関数型リテラル、もう1つはオブジェクト型の中にメンバーとして書く呼び出しシグネチャです。次の ToLabelToLabel2 はまったく同じ型で、どちらに対しても同じ関数を代入できます。

signature.ts
// (1) アロー記法(関数型リテラル)
type ToLabel = (value: number) => string;

// (2) 呼び出しシグネチャ(オブジェクト型のメンバーとして書く)
interface ToLabel2 {
  (value: number): string;
}

// どちらにも同じ関数を代入できる
const toLabel: ToLabel = (value) => `${value} 円`;
const toLabel2: ToLabel2 = (value) => `${value} 円`;

矢印が : に変わっただけで、意味は同じです。「オブジェクトの中に (value: number): string と書くと、そのオブジェクト自体を関数として呼べる」という宣言になり、これを呼び出しシグネチャと呼びます。JavaScript の関数はオブジェクトの一種なので、TypeScript ではこのように関数をオブジェクト型の一機能として表現しているわけです。

では、わざわざ長く書ける呼び出しシグネチャは何のためにあるのでしょうか。理由は、アロー記法が「関数であること」しか表せないのに対し、呼び出しシグネチャなら同じ型にプロパティやメソッドを一緒に書ける点にあります。まずは書き方の対応関係を押さえておきましょう。

書き方名前意味
(x: number) => string関数型リテラル数値を受け取り文字列を返す関数
{ (x: number): string }呼び出しシグネチャ同じ意味。プロパティを併記できる
new (x: string) => Personコンストラクタ型リテラルnew で呼べて Person を生成するもの
{ new (x: string): Person }コンストラクトシグネチャ同じ意味。static メンバーを併記できる

type エイリアスでも呼び出しシグネチャは書ける

呼び出しシグネチャは interface 専用の機能ではありません。type エイリアスの右側に波かっこでオブジェクト型を書けば、まったく同じことが表現できます。つまり type Fn = () => voidtype Fn = { (): void } の短縮記法だと考えると、両者の関係がすっきりします。

type-alias.ts
// この3つはすべて同じ型
type A = (value: number) => string;
type B = { (value: number): string };
interface C {
  (value: number): string;
}

const a: A = (v) => String(v);
const b: B = a; // 相互に代入できる
const c: C = b;

普段のコードでは、単純な関数型はアロー記法のほうが短く読みやすいので、そちらを使えば十分です。呼び出しシグネチャの形にするのは、これから紹介する「プロパティも持たせたい」「複数のシグネチャを並べたい」といった、アロー記法では表現できない場面に限られます。

関数でありながらプロパティも持つ型

呼び出しシグネチャがいちばん力を発揮するのが、この用途です。JavaScript では関数にプロパティを付けられるので、「関数として呼べるが、設定値やメソッドも持っている」オブジェクトが珍しくありません。jQuery$() や、テストライブラリの describe.only() のような形です。これはアロー記法では書けませんが、呼び出しシグネチャなら自然に表現できます。

counter.ts
interface Counter {
  // 呼び出しシグネチャ:counter(10) のように呼べる
  (start: number): string;
  // メソッド
  reset(): void;
  // プロパティ
  count: number;
}

この Counter 型の値は、counter(10) と呼び出すことも counter.count を読むことも counter.reset() を呼ぶこともできます。実装側は、関数宣言に対してあとからプロパティを代入する形で作れます。TypeScript は同じスコープで関数宣言に代入されたプロパティを、その関数の型の一部として認識するため、キャストなしでも通ります。

counter-impl.ts
function counter(start: number): string {
  counter.count = start;
  return `${start} から開始しました`;
}

// 関数にプロパティを足していく
counter.count = 0;
counter.reset = (): void => {
  counter.count = 0;
};

// Counter 型として扱える
const c: Counter = counter;

console.log(c(10));      // 10 から開始しました
console.log(c.count);    // 10
c.reset();
console.log(c.count);    // 0

アロー関数式で同じことをしたい場合は、Object.assign() を使うと型が付いたまま組み立てられます。Object.assign(fn, { count: 0 }) の戻り値は「関数とオブジェクトの交差型」になるため、結果をそのまま Counter 型の変数に代入できます。ここで大事なのは、アロー記法の型では受け皿を作れないということです。type Counter = (start: number) => string と書いてしまうと counter.count でエラーになります。

呼び出しシグネチャを並べてオーバーロードを表す

呼び出しシグネチャは、1つの型の中にいくつも並べて書けます。これは「引数の組み合わせによって戻り値の型が変わる」関数、いわゆるオーバーロードを型として表現する方法です。次の Reverse は、文字列を渡せば文字列、数値の配列を渡せば数値の配列が返ってくる関数の型です。

overload.ts
interface Reverse {
  (value: string): string;
  (value: number[]): number[];
}

const reverse: Reverse = (value: any): any =>
  typeof value === 'string'
    ? value.split('').reverse().join('')
    : [...value].reverse();

const s = reverse('abc');      // s: string
const n = reverse([1, 2, 3]);  // n: number[]

呼び出し側では、渡した引数に応じて戻り値の型が切り替わります。TypeScript は並べたシグネチャを上から順に照合し、最初にマッチしたものを使うため、より限定的なシグネチャを先に書くのが原則です。たとえば (value: unknown): unknown のような何でも受け付ける行を先頭に置くと、以降のシグネチャは使われなくなります。

一方、アロー記法1つでは1組の引数と戻り値しか書けません。どうしてもアロー記法で表したい場合は、関数型を交差型(&)でつなぐとオーバーロードと同じ挙動になります。ただし読みやすさでは呼び出しシグネチャを並べたほうが明快なので、通常はそちらを選びます。

intersection.ts
// 交差型で書いても上の Reverse とほぼ同じ意味になる
type Reverse2 = ((value: string) => string) & ((value: number[]) => number[]);

// これは書けない(1つの関数型リテラルには1組しか書けない)
// type NG = (value: string) => string, (value: number[]) => number[];

new で呼ぶ型を表すコンストラクトシグネチャ

呼び出しシグネチャの先頭に new を付けると、new 演算子と一緒に呼べるもの、つまりクラスやコンストラクタ関数を表す型になります。これがコンストラクトシグネチャです。new (name: string): Person は「文字列を1つ渡して new すると Person のインスタンスができる」という意味になります。

construct.ts
class Person {
  constructor(public name: string) {}

  greet(): string {
    return `${this.name} です`;
  }
}

// コンストラクトシグネチャ
interface PersonConstructor {
  new (name: string): Person;
}

// 短縮記法(コンストラクタ型リテラル)
type PersonConstructor2 = new (name: string) => Person;

// クラスそのものを代入できる(インスタンスではない点に注意)
const Ctor: PersonConstructor = Person;
const taro = new Ctor('太郎'); // taro: Person

ここで代入しているのは new Person('太郎') の結果ではなく、Person クラスそのものです。呼び出しシグネチャと同じように、interface の中に書く形(: を使う)と、単独の型として書く短縮記法(=> を使う)の2通りがあります。

クラスを引数で受け取るファクトリ関数

コンストラクトシグネチャの出番は、クラスを値として渡すときです。「渡されたクラスをインスタンス化して返す」ファクトリ関数や、依存する実装を外から差し替える依存性注入(DI)のような設計で必要になります。ジェネリクスと組み合わせると、渡したクラスに応じた型のインスタンスが返るようにできます。

factory.ts
// 名前の配列から、指定されたクラスのインスタンスをまとめて作る
function createAll<T>(Ctor: new (name: string) => T, names: string[]): T[] {
  return names.map((name) => new Ctor(name));
}

class Admin {
  constructor(public name: string) {}
  role = 'admin';
}

const people = createAll(Person, ['太郎', '花子']); // people: Person[]
const admins = createAll(Admin, ['管理者']);        // admins: Admin[]

引数の型を Person にしてしまうと「Person のインスタンス」を要求することになり、クラスを渡した時点でエラーになります。new (...) => T と書くことで「クラス側(コンストラクタ)を受け取る」と明示できるわけです。DI であれば、同じインターフェースを実装した複数のクラスをコンストラクタ型で受け取り、状況に応じて差し替えられます。

di.ts
interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  constructor(private prefix: string) {}
  log(message: string): void {
    console.log(`[${this.prefix}] ${message}`);
  }
}

// 「Logger を作れるクラス」を受け取る
type LoggerConstructor = new (prefix: string) => Logger;

function createLogger(Ctor: LoggerConstructor, prefix: string): Logger {
  return new Ctor(prefix);
}

const logger = createLogger(ConsoleLogger, 'app');
logger.log('起動しました'); // [app] 起動しました

abstract なクラスを受け取りたいとき

abstract classnew できないため、普通のコンストラクトシグネチャには代入できません。抽象クラスも受け取れるようにしたい場合は、TypeScript 4.2 で追加された abstract new (...) という書き方を使います。abstract を付けた型には抽象クラスも具象クラスも代入できますが、その型の値を直接 new することはできなくなります。継承元として渡すだけ、という用途に向いた書き方です。

abstract.ts
abstract class Shape {
  abstract area(): number;
}

// abstract を付けると抽象クラスも渡せる
type ShapeClass = abstract new (...args: any[]) => Shape;

function describe(Ctor: ShapeClass): string {
  return Ctor.name;
}

console.log(describe(Shape)); // Shape

typeof クラス名 との関係

クラスを受け取りたいとき、コンストラクトシグネチャを手で書く代わりに typeof クラス名 と書く方法もあります。クラスには「インスタンス側の型」と「クラス自身(static 側)の型」の2つがあり、Person と書けば前者、typeof Person と書けば後者を指します。そしてクラス自身の型は、コンストラクトシグネチャと static メンバーを持つオブジェクト型です。

typeof-class.ts
class User {
  static tableName = 'users';
  constructor(public id: number) {}
}

// typeof User は、およそ次のような型
interface UserClass {
  new (id: number): User;
  tableName: string;
}

const cls: typeof User = User;
console.log(cls.tableName);      // users
const u = new cls(1);            // u: User

// インスタンス側の型を取り出す
type UserInstance = InstanceType<typeof User>; // User

両者の使い分けは「その1つのクラスに限定したいか」で決まります。typeof UserUser(とそのサブクラス)だけを受け付け、static メンバーへのアクセスもそのまま型が付きます。対して new (id: number) => User形が合えばどんなクラスでも受け付けるため、実装を差し替えられる汎用的な関数に向いています。

書き方指すもの向いている場面
Userインスタンスの型作られたオブジェクトを受け取る
typeof Userクラス自身(コンストラクタ+static)特定のクラスだけを渡したい、static も使う
new (id: number) => User同じ形のコンストラクタすべて実装を差し替えるファクトリ・DI
InstanceType<typeof User>クラス型からインスタンスの型クラス型しか手元にないとき

ジェネリックな呼び出しシグネチャ

呼び出しシグネチャにも型引数を付けられます。このとき、型引数をシグネチャに付けるのか、interface 自体に付けるのかで意味が変わります。シグネチャに付けた型引数は呼び出しごとに決まり、interface に付けた型引数は型を書いた時点で決まります。

generic-signature.ts
// (1) シグネチャに型引数:呼び出すたびに T, U が決まる
interface Mapper {
  <T, U>(items: T[], fn: (item: T) => U): U[];
}

const map: Mapper = (items, fn) => items.map(fn);

const lengths = map(['a', 'bb'], (s) => s.length); // number[]
const labels = map([1, 2], (n) => `#${n}`);        // string[]

// (2) interface に型引数:型を書くときに固定される
interface Wrapper<T> {
  (value: T): T[];
}

const wrapNumber: Wrapper<number> = (value) => [value];
wrapNumber(1);
// wrapNumber('a'); // エラー:T は number に固定されている

アロー記法でも type Mapper = <T, U>(items: T[], fn: (item: T) => U) => U[] のように型引数を書けます。ただし、ジェネリックな呼び出しに加えてプロパティやオーバーロードも持たせたい場合は、やはり呼び出しシグネチャの形が必要になります。

: と => の書き分けで迷うとき

呼び出しシグネチャでつまずく原因のほとんどが、この記号の使い分けです。ざっくり言えば、オブジェクト型の「中」では :、型として「単独で」書くときは => です。文法エラーになったら、いま自分が波かっこの中にいるのかどうかを確認してください。

書く場所記号
型として単独で書く(関数型リテラル)=>type Fn = (x: number) => string
オブジェクト型の中の呼び出しシグネチャ:interface Fn { (x: number): string }
オブジェクト型のメソッド:{ reset(): void }
関数型のプロパティ=>{ onClick: () => void }
コンストラクタ型を単独で書く=>type C = new (x: string) => Person
オブジェクト型の中のコンストラクトシグネチャ:{ new (x: string): Person }

interface の中に矢印を書くと構文エラーになる

interface Fn { (x: number) => string } と書くと構文エラーになります。オブジェクト型のメンバーは「名前 : 型」という形をとる決まりで、呼び出しシグネチャは名前が省略されたメンバーという扱いだからです。矢印を使えるのは = の右側に型そのものを書くときだけだと覚えておけば混乱しません。

プロパティとして書くと「呼べる型」にならない

もう1つの典型的な取り違えが、呼び出しシグネチャのつもりで名前付きプロパティを書いてしまうケースです。次の Handler1Handler2 はまったく別物です。前者は「オブジェクト自体を関数として呼べる」型、後者は「handle という関数プロパティを持つオブジェクト」の型です。

confusing.ts
interface Handler1 {
  (message: string): void; // 呼び出しシグネチャ
}

interface Handler2 {
  handle: (message: string) => void; // ただのプロパティ
}

const h1: Handler1 = (message) => console.log(message);
h1('hello'); // OK

const h2: Handler2 = { handle: (message) => console.log(message) };
h2.handle('hello'); // OK
// h2('hello');     // エラー:この式は呼び出し可能ではありません

「この式は呼び出し可能ではありません(This expression is not callable.)」というエラーが出たときは、型の側に呼び出しシグネチャがないことを疑ってください。逆に「オブジェクトリテラルを代入したいのに型が合わない」場合は、呼び出しシグネチャのある型に {} を渡していないか確認します。

メソッド記法とプロパティ記法では引数チェックの厳しさが違う

同じ「関数を持つメンバー」でも、reset(): void というメソッド記法と reset: () => void というプロパティ記法では、型の互換性チェックの厳しさが変わります。strictFunctionTypesstrict に含まれます)が有効な場合、プロパティ記法の関数型は引数の型を厳しく(反変で)チェックし、メソッド記法は緩く(双変で)チェックします。配列の push のような既存 API との互換性を保つためにこうなっています。厳密さを求めるならプロパティ記法、既存のクラス階層に合わせたいならメソッド記法、と使い分けます。

class では implements してもコンストラクタは検査されない

コンストラクトシグネチャを持つ interfaceclass X implements PersonConstructor のように実装しようとしても、意図した検査は行われません。implements が見るのはインスタンス側の型だけで、クラス自身(static 側)の型は対象外だからです。「このクラスは指定どおりのコンストラクタを持っているか」を確かめたいときは、const Ctor: PersonConstructor = Person; のようにクラスをコンストラクタ型の変数に代入してチェックします。

まとめ

関数の型は、単独で書くアロー記法((x: number) => string)と、オブジェクト型の中に書く呼び出しシグネチャ((x: number): string)の2通りで表せます。単純な関数型ならアロー記法で十分ですが、プロパティやメソッドを併せ持つ関数複数シグネチャによるオーバーロードは呼び出しシグネチャでしか書けません。type エイリアスでも type Fn = { (): void } と書けるので、interface 専用の機能ではないことも押さえておきましょう。new を付けたコンストラクトシグネチャ(new (name: string): Person)はクラスそのものを表す型で、ファクトリ関数や依存性注入でクラスを引数として受け取るときに使います。特定のクラスに限定したいなら typeof クラス名、形が合うクラスを広く受け取りたいならコンストラクタ型、という使い分けです。最後に :=> は「波かっこの中なら :、単独の型なら =>」と覚えておけば、書き間違いはほとんどなくなります。

参考ページ