Programming Field

[TypeScript] 関数・クラスの型引数の指定を必須にする裏技(?)

TypeScriptのジェネリックス(generics)では型引数(型パラメーター)を与えることで型の安全性を向上することができますが、関数やクラスにおいては(型引数の既定値の指定が無い場合でも)型引数の指定が無くても特に問題なくビルドが通ります。

型を明示的に指定しない場合は unknown になる

現時点(TS 5.0)で型引数を明示的に指定させるように強制する文法やコンパイルオプションなどはありませんが、型引数を明示的に指定させたい場合は次に紹介する方法が使えます。

引数のある関数・クラスの型引数を指定必須にする - 型引数が引数に使われている場合

型引数が引数に使われている場合、型引数の既定を never にしつつ、型引数が使われている引数の推論をできないような工夫を入れてあげることで、結果型引数の指定を必須にすることができます。

「引数の推論をできないような工夫」としては、以下のような NoInfer という型を定義して使う方法があります。

type NoInfer<T> = [T][T extends any ? 0 : never];

これを、型引数を使う場所に適用します。

function hoge<T = never>(arg: NoInfer<T>): void {}

こうすることで、関数の呼び出し時に型引数を明示的に指定しない場合に引数が never となってほとんどのケースでエラーになります。

hoge<number>(1); // ok
hoge(1); // エラー TS2345: 「型 'number' の引数を型 'never' のパラメーターに割り当てることはできません。」

NoInfer により推論されなくなる

クラスの場合はコンストラクターの引数で調整します。

class Piyo<T = never> {
    constructor(arg: NoInfer<T>) { }
}
new Piyo<number>(1); // ok
new Piyo(1); // エラー

引数のある関数・クラスの型引数を指定必須にする - 型引数が引数に使われていない場合

前の項では型引数が引数に使われている場合でしたが、引数に使われていない場合は上記の方法をそのまま使うことはできません。しかし、引数自体が存在するのであれば、その引数を条件に応じて never にすり替えることで、実質的に型引数の指定を必須とすることができます。

具体的には、指定必須の引数の型を、条件付き型を用いて「型引数が never であれば never」にすることで、その引数を指定しようとしてエラーにすることができます。(元ネタ: javascript - TypeScript require generic parameter to be provided - Stack Overflow)

function foo<T = never>(param: [T] extends [never] ? never : string): T {
    // param は string として扱うことができる (string | never は string であるため)
    console.log(param.length);
    return { __type: param } as T;
}
foo<{}>('foo'); // ok
foo('foo'); // エラー

※ 型引数が never かどうかをチェックするには、「T extends never ?」ではなく「[T] extends [never] ?」などとする必要があります。参考: Generic conditional type T extends never ? 'yes' : 'no' resolves to never when T is never. · Issue #31751 · microsoft/TypeScript

クラスのコンストラクターについても同様に対応できます。

引数のない関数・クラスの型引数を指定必須にする

では引数がない場合はどうすればいいか、という点が残りますが、条件付き型を工夫することで指定漏れの場合はほとんどのケースでエラーにすることができます。

その方法は、「型引数が never であれば関数の引数を『never を指定しなければならない引数』、それ以外であれば引数なしにする」というコードにします。

type NoInfer<T> = [T][T extends any ? 0 : never];
function bar<T extends object = never>(
    ...args: [T] extends [never] ? [invalid: never] : []
): NoInfer<T> {
    return {} as T;
}

const o1 = bar<{}>(); // ok
const o2: {} = bar(); // エラー TS2554: 「1 個の引数が必要ですが、0 個指定されました。」

※ 上記の場合に限らず、「T = never」とする場合は、T の型に制約を設けて「T extends object = never」などとすることも可能です。
※ 「[invalid: never]」はラベル付きtuple型で、ラベルを付けることでそれが引数の名前に使用されます。

クラスの場合は同様にコンストラクターで調整します。

class Baz<T = never> {
    constructor(...args: [T] extends [never] ? [invalid: never] : []) { }
    foo(arg: T) {}
}
const b1 = new Baz<string>(); // ok
const b2: Baz<string> = new Baz(); // これもok
const b3 = new Baz(); // エラー

なお、型引数が never ではない場合に使うtuple型として空tupleを使っていますが、別のtupleを使用すれば引数のある関数などにも対応することができます。

まとめ

  • 型引数が引数の型として使われる場合は、推論されない工夫を入れることで対応できます。
  • 型引数が引数の型として使われないものの、別の必須引数が存在する場合は、その引数を条件付き型で never になるようにすればエラーにすることができます。
  • そもそも引数が存在しない場合は、可変引数と条件付き型を用いて、型引数が指定されていなければ「実質指定できない引数」を必須にすることでエラーにすることができます。