2
2

JavaScript における型判定手法について

Last updated at Posted at 2024-08-26

動的型付けの言語を扱っていれば、任意の型の値が入ってくる場合があるだろうし、その型を特定したいという需要がしばしば存在する。例えば 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 から作ったサブクラスの値も .constructorString ではなくなってしまうので、判定できない。

手法3: ObjecttoString を使った方法

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 とは

  1. Object.prototypeObject 型のプロトタイプオブジェクトを返す。
    • プロトタイプとは Object 型や、 Object を継承した全ての型の値の「原型」となる値である。つまり、プロトタイプに属性を追加すれば、これら全ての値に反映される。
  2. Object.prototype.toString とは Object 型についてくる、文字列表現を返す toString メソッドのことである。
  3. .call() とは、 .toString 関数の this を渡した値に「すり替えた」上で toString を実行するということである。通常の Object 型の値 a に対して a.toString() を実行すると、 toString 関数内での変数 thisa となり、 a について文字列表現をするが、これを call に渡した値にすり替えることにより、その値についての文字列表現を返す。

ObjecttoString 関数では、内部的に用意されているクラスに関する情報をもとに [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]

つまり、 ObjecttoString を使った方法でも書き換えによって正常に判定できなくなることがあることには注意する。

逆に言えば、自作クラスを作る際には 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 要素に対する childNodeschildren とか、 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 !== nullnull が排除され、最後の !isIterable(value) で配列のようなもの (イテレータ) の可能性を排除している。

型の判定方法: nullundefined

nullundefined は普通に等価演算子で調べれば良さそうである。

const isNull = (value) => value === null;
const isUndefined = (value) => value === undefined;
const isNil = (value) => value == null;

isNilnullundefined の両方にマッチする。

TypeScript における型

JavaScript を使う場面は TypeScript を使っていることも多いので、 TypeScript として型を持った関数として実装したい。

プリミティブ型について

TypeScript ではプリミティブ型と、そのラッパーオブジェクトに対して、異なる型が用意されている。

プリミティブ型 ラッパーオブジェクトの型
string String
number Number
boolean Boolean
bigint BigInt
symbol Symbol

TypeScript の公式ドキュメントには次のように書かれている。

The type names String, Number, and Boolean (starting with capital letters) are legal, but refer to some special built-in types that will very rarely appear in your code. Always use string, number, or boolean 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}.`);
	}
}

関数の引数 valueNullable<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 であると valuenull 又は 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);
2
2
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
2
2