動的型付けの言語を扱っていれば、任意の型の値が入ってくる場合があるだろうし、その型を特定したいという需要がしばしば存在する。例えば Python であれば次のようにして型を判定することができる。
# 判定する関数
def is_str(value): bool:
return type(value) is str
is_str("文字列") # -> True
is_str(42) # -> False
割と簡単に文字列かどうかを判定する関数を実装できるであろう。
しかし、 JavaScript においては、型判定の手法は上記ほどに一筋縄ではないようだ。
本記事では特定の型であることを判定する手法を幾つか紹介する。
また、こうした型を判定する手法を TypeScript で実装するには工夫が必要だと考えられるので、 TypeScript で型判定を実装するにあたって意識した方がいい内容を合わせて説明する。
但し、ここで記載する方法は完璧な方法だとは思ってないので、他にも方法があったら教えてください。
型の判定方法: 文字列を例として
まず型判定の手法について、具体的にどういった手法があるか紹介する。
様々な型に対する型判定関数を用意するつもりでいるが、ここでは文字列型 string
の場合を例として説明する。
const isString = (value) => {
return /* ここに何を記述するか... */;
};
手法1: typeof
を使った方法
1番典型的なものは、次のように typeof
という演算子を使って判定する方法である。
const isString = (value) => {
return typeof value === "string";
};
isString("文字列") // -> true
isString([1,2,3]) // -> false
これは typeof
が値の種類によって "string"
とか "number"
とか "object"
などと文字列で型を返すことを利用しており、基本的にはこれで問題はない。
しかし、少し値をいじってしまうと typeof
は正しく判定しなくなる。
例えば次のように typeof
でうまく動作しなくなるケースがある。
const a = "text";
const b = Object(a);
const c = new String(a);
typeof a // -> "string"
typeof b // -> "object"
typeof c // -> "object"
これは所謂プリミティブ型とラッパー型の違いによるものである。
JavaScript では string
, number
, boolean
, bigint
, symbol
, null
, undefined
はプリミティブ型とされている。これらはオブジェクトではないからプロパティを持たず、またその値自身は書き換えできない。
一方で、文字列型の値 s
に対して s.length
とか s.splice
とかいうプロパティやメソッドが存在するが、これらは String
クラスにおけるプロパティやメソッドである。プリミティブな string
に対して .length
や .splice
などを呼んだときには、 String
クラスのオブジェクトに変換されて呼び出されている。こうした string
に対する String
クラスのオブジェクトのことをラッパーオブジェクトと呼ぶ。
そして、 typeof
では文字列のプリミティブ型は "string"
を返し、ラッパーオブジェクトは "object"
を返すようになっている。
上記の例では、 a
はプリミティブ型であるが b
, c
がラッパーオブジェクトに変換されているために、異なる結果を返しているようだ。
「私は絶対プリミティブ型しか扱わない!」というのであればこの方法で問題ないが、任意の値が入ってき得るケースにおいて String
のラッパーオブジェクトが入ってきて、文字列ではないと判定されるのは困るといったケースでは typeof
を使った判定はあまり好ましくない。
参考: typeof
の挙動
typeof
の挙動はこのような感じである。
-
string
,number
,boolean
,bigint
,symbol
について- プリミティブ型そのものなら
"string"
,"number"
などを返す - ラッパーオブジェクトになると
"object"
を返す
- プリミティブ型そのものなら
-
undefined
は"undefined"
を返す (ラッパーオブジェクトは存在しない?) -
null
は常に"object"
を返す (ヌルポインタに相当するから) - 関数型は
typeof
で"function"
を返す- しかもプリミティブ型とは違い、
Object( )
やnew Function( )
を通しても常に"function"
を返す
- しかもプリミティブ型とは違い、
- その他の非プリミティブ型は常に
"object"
を返す
つまり、非プリミティブ型に関してはほとんど役に立たない。
手法2: constructor
を使った方法
値の生成元クラスを得るプロパティである .constructor
を使う方法である。これは最初に示した Python の方法に近い方法である。
const isString = (value) => {
// 先に null や undefined の可能性を排除する
if (value==null) return false;
return value.constructor === String;
};
この方法であればプリミティブではない型についても、それぞれのクラスを知ることができて良さそうである。
しかし、そもそも .constructor
プロパティの値は簡単に書き換えられてしまうため、この方法だと書き換えられてしまった値については正確に判定することはできない。
String
から作ったサブクラスの値も .constructor
は String
ではなくなってしまうので、判定できない。
手法3: Object
の toString
を使った方法
Object
クラスの値に含まれる toString
メソッドの値を使って判定する方法である。これであれば .constructor
が書き換えられても、サブクラスであっても安全に判定できる。
const isString = (value) => {
return Object.prototype.toString.call(value) === "[object String]";
};
isString("文字列") // -> true
// constructor が偽装されても大丈夫
const a = "文字列";
a.constructor = Object;
isString(a) // -> true
// サブクラスであっても大丈夫
class SubString extends String {}
const b = new SubString("文字列");
isString(b) // -> true
Object.prototype.toString.call
とは
-
Object.prototype
はObject
型のプロトタイプオブジェクトを返す。- プロトタイプとは
Object
型や、Object
を継承した全ての型の値の「原型」となる値である。つまり、プロトタイプに属性を追加すれば、これら全ての値に反映される。
- プロトタイプとは
-
Object.prototype.toString
とはObject
型についてくる、文字列表現を返すtoString
メソッドのことである。 -
.call()
とは、.toString
関数のthis
を渡した値に「すり替えた」上でtoString
を実行するということである。通常のObject
型の値a
に対してa.toString()
を実行すると、toString
関数内での変数this
はa
となり、a
について文字列表現をするが、これをcall
に渡した値にすり替えることにより、その値についての文字列表現を返す。
Object
の toString
関数では、内部的に用意されているクラスに関する情報をもとに [object String]
や [object Array]
などのクラス名を示した文字列を返す。サブクラスを作っても継承元の標準で用意されたクラスの結果を返す (これを変える方法は以下で説明する)。
これを聞くと .constructor
よりは安全そうに見えるが、これもやはり書き換えることはできる。次のようにオブジェクトの Symbol.toStringTag
プロパティを書き換えることで、内部情報よりも優先されてユーザーの指定した値が表示されるようになる。
let a = {};
Object.prototype.toString.call(a) // -> [object Object]
a[Symbol.toSymbolTag] = "FakeTag";
Object.prototype.toString.call(a) // -> [object FakeTag]
つまり、 Object
と toString
を使った方法でも書き換えによって正常に判定できなくなることがあることには注意する。
逆に言えば、自作クラスを作る際には Symbol.toStringTag
のゲッターを生やしておけば、標準クラスのように見せることができるし、型判定で自作クラスを判別することができるようになる。但し、あくまで文字列の等価性での判定なので、同名のクラスが存在すれば区別できなくなってしまうが...
class MyClass {
get [Symbol.toStringTag]() {
return "MyClass";
}
}
※ ゲッターを生やせば標準クラスのように見せかけることができると説明したが、多くの標準クラスの値では Symbol.toStringTag
プロパティは設定されてないようなので、 Symbol.toStringTag
プロパティの存在有無を調べれば標準クラスに紛れた自作クラスをある程度炙り出すことはできそう。
const a = new String("text");
a[Symbol.toStringTag] // undefined
手法4: instanceof
を使った方法
typeof
と同じく演算子の instanceof
を使って判定する手法である。
const isString = (value) => {
return value instanceof String;
};
これは typeof
とは逆でプリミティブ型は判定できず、オブジェクトのみを判定することができる。
const a = "text";
const b = Object(a);
const c = new String(a);
const d = new Number(42);
a instanceof String // -> false
b instanceof String // -> true
c instanceof String // -> true
d instanceof String // -> false
typeof
がオブジェクトであることが判定できても、オブジェクトの具体的な内容が判定できないことを補うような機能になっている。手法3のように文字列の等価性による判定ではないから、同名のクラスがあっても安全に判定できる。
手法2,手法3では結果を改竄することができる話をしてきたが、こちらも同様に改竄することができてしまう。というのも、この真偽の判定はオブジェクトの Symbol.hasInstance
メソッド に依拠しているので、これを書き換えてしまえばいい。
// String が全て偽の判定をするように書き換える
Object.defineProperty(String, Symbol.hasInstance, {
value: (instance) => false
});
const c = new String("text");
c instanceof String // false
手法のまとめ
ここまで4つの手法を見てきた。
先ほども言ったが、プリミティブ型に限定すれば typeof
を使った手法1で十分であるが、やはりラッパー型にも対応させたい。
改竄される危険性はあるものの、ラッパーしたものも含むオブジェクトの判定には instanceof
を使った手法4が最も確実そうである。
つまり、文字列を判定するための関数は次のようになる。
const isString = (value) => {
return typeof value === "string" || value instanceof String;
};
また、プリミティブでない他のクラスの判定には instanceof
を使えば良さそうである。
const isURL = (value) => {
return value instanceof URL;
};
型の判定方法: 配列の場合
配列はプリミティブ型ではないので、 value instanceof Array
で調べることはできる。だが、ここでは配列に特有の内容が幾つかあるのでまとめる。
配列のための専用の判定方法
配列を判定する方法として標準で Array.isArray
が用意されている。
const isArray = (value) => Array.isArray(value);
これは、 instanecof
が偽装されても動作する。
// Array で instanceof が正常に動作しないようにする
Object.defineProperty(Array, Symbol.hasInstance, {
value: (instance) => false
});
[] instanceof Array // -> false
Array.isArray([]) // -> true
要素の型まで含めた判定方法
上記の配列の判定方法は、要素の種類については判定していない。つまり型は any[]
となる。これを要素に含まれる型まで厳密にチェックするのであれば、次のようにする。
// string[] かどうかの判定
const isStringArray = (value) => {
Array.isArray(value) && value.every(isString);
};
ただ、これは要素全てを走査することになるから、パフォーマンスが求められる場面ではあまり好ましくないな...
配列のようなオブジェクトも含めた判定方法
所謂 Array
クラス以外にも配列っぽい挙動を示すクラスは幾つか存在する。例えば DOM 要素に対する childNodes
や children
とか、 RegExp
のキャプチャグループが挙げられる。
これらは Iterator
になっているらしく、これらまで含めてマッチさせたい場合は、 Symbol.iterator
プロパティの存在を確認する。
const isIterator = (value) => {
if (value==null) return false;
return typeof value[Symbol.iterator] === "function";
};
型の判定方法: オブジェクト
配列に対して、文字列のキーに対して値を持った型としてのオブジェクトを見つける需要が存在する場合、次のようにすれば良さそうである。
const isObject = (value) => {
return typeof value === "object" && value !== null && !isIterable(value);
};
まず typeof value === "object"
で任意クラスのオブジェクトと null
に絞られ、次の value !== null
で null
が排除され、最後の !isIterable(value)
で配列のようなもの (イテレータ) の可能性を排除している。
型の判定方法: null
と undefined
null
や undefined
は普通に等価演算子で調べれば良さそうである。
const isNull = (value) => value === null;
const isUndefined = (value) => value === undefined;
const isNil = (value) => value == null;
isNil
は null
と undefined
の両方にマッチする。
TypeScript における型
JavaScript を使う場面は TypeScript を使っていることも多いので、 TypeScript として型を持った関数として実装したい。
プリミティブ型について
TypeScript ではプリミティブ型と、そのラッパーオブジェクトに対して、異なる型が用意されている。
プリミティブ型 | ラッパーオブジェクトの型 |
---|---|
string |
String |
number |
Number |
boolean |
Boolean |
bigint |
BigInt |
symbol |
Symbol |
TypeScript の公式ドキュメントには次のように書かれている。
The type names
String
,Number
, andBoolean
(starting with capital letters) are legal, but refer to some special built-in types that will very rarely appear in your code. Always usestring
,number
, orboolean
for types.
なので上記のラッパーオブジェクト向けの型は普通は使用しない方がいい。
しかし、任意のオブジェクトに対して型判定を行い、ラッパーオブジェクトも文字列などとして適切に判定してほしい、と考えている我々はラッパーオブジェクトも扱える型は必要である。 String
型の値を string
型の変数に代入することはできないのだから、 String
を使わざるを得ない。
型判定関数の戻り値の型
TypeScript では条件分岐や三項演算子において条件式に型が絞り込めるような式が含まれていると、条件式に合わせて分岐先の式の型を決めてくれるようだ。
次の関数を例に説明する。
function describe(value: Nullable<string>) {
// value は string | null | undefined 型である。
if ( value == null ) {
// value が null か undefined の場合しか到達しない
console.log("The given value is not a string.");
} else {
// value が string 型の場合しか到達しないため、 value は既にアンラップされている。
console.log(`The given value is a string whose length is ${value.length}.`);
}
}
関数の引数 value
は Nullable<string>
、つまり string | null | undefined
という論理和型である。
TypeScript では value == null
という条件式により else
節には string
の場合しか到達しないことを察知して、 else
節内の変数 value
は明示的にアンラップ操作をしなくても単純な string
型だとみなしてくれる。
他の言語における if let { } else { }
ではアンラップした文字列型を別の変数に置いた上で if
節が実行されるのとは違っている。
この条件式を value == null
から等価な isNil(value)
に書き換えても動作するようにしたい。そのためには isNil
関数の型の指定を工夫する必要がある。
関数 isNil
を型を明示して定義すると次のようになるだろう。
type IsNil = (value:unknown) => boolean; // 関数の型を予め定義している
const isNil: IsNil = (value) => value == null;
この定義だと戻り値はブール型であり、 Nullable<string>
をアンラップできる条件式が作れるか、という情報は含まれていないため、 describe
関数の else
節の value
は依然として Nullable<string>
になってしまい、コンパイルエラーとなる。
正常にコンパイルさせるにはブール値が「引数が null
であることを示すブール値」であるという情報を与えなければならない。そのために次のように書き換える必要がある。
type Nil = null | undefined; // これで Nullable<string> = string | Nil になる
type IsNil = (value: unknown) => value is Nil;
const isNil: IsNil = (value) => value == null;
型として boolean
の代わりに value is Nil
を与えることで、 isNil
関数の結果が true
であると value
が null
又は undefined
であると宣言したことになる。
同様にすれば isString
関数も次のようになるだろう。
type IsString = (value: unknown) => value is String;
const isString = (value) => {
return typeof value === "string" || value instanceof String;
}
型変数を使った抽象化
TypeScript を使って型を導入したからには、型変数を使って楽をしたい。
型判定関数
次のように定義する。
type Is<T> = (value: unknown) => value is T;
すると関数を実装する度に型定義をする必要はなくなる。
const isNil: Is<Nil> = ...;
const isString: Is<String> = ...;
関数をまとめて生成
型判定関数の実装は似ているところが多いので、まとめて生成させることもできる。
// プリミティブ型およびそのラッパーオブジェクト向け
const makeIsPrimitiveOrWrapper = <T,>(typeofVal:string,cls:Function): Is<T> => {
return (value) => {
return ( typeof value === typeofVal ) || ( value instanceof cls )
};
};
// オブジェクト向け
const makeIsObjectType = <T,>(typeofVal:string,cls:Function): Is<T> => {
return (value) => value instanceof cls;
};
const isString = makeIsPrimitiveOrWrapper<String>("string",String);
const isNumber = makeIsPrimitiveOrWrapper<Number>("number",Number);
const isBoolean = makeIsPrimitiveOrWrapper<Boolean>("boolean",Boolean);
const isBigInt = makeIsPrimitiveOrWrapper<BigInt>("bigint",BigInt);
const isSymbol = makeIsPrimitiveOrWrapper<Symbol>("symbol",Symbol);
const isRegExp = makeIsObjectType<RegExp>(RegExp);
const isDate = makeIsObjectType<Date>(Date);
const isMap = makeIsObjectType<Map>(Map);
// ...etc
配列の判定関数
配列であるか判定する関数は TypeScript では次のように記載できる。
const isArray: Is<any[]> = (value) => Array.isArray(value);
実際に使う場面では any[]
よりも string[]
のように具体的な要素の型の情報もあった方がいいから isTypedArray
は必要で
const isTypedArray = <T>(value: unknown, isTypeForItems: Is<T>): value is Is<T[]> {
if (!isArray(value)) return false;
// この時点で value is any[]
return value.every(isTypeForItems);
};
// 例
function describe(value:unknown) {
if (!isTypedArray(value,isString)) console.log("The given value is not a string[].");
else console.log(`The given value is string[]: ${value.join(", ")}`);
}
string[]
などのようによく使われる型は、各要素を判定するための isString
などを与えるのは面倒なので、ある程度用意しておこう。
const makeIsTypedArray = <T>(isTypeForItems: Is<T>): Is<T[]> => {
return (value) => isTypedArray(value, isTypeForItems);
};
const isStringArray: Is<String[]> = makeIsTypedArray(isString);
const isNumberArray: Is<Number[]> = makeIsTypedArray(isNumber);
const isBooleanArray: Is<Boolean[]> = makeIsTypedArray(isBoolean);