初書:2021/08/06
typescript:4.6.3
前書き
typescriptで、基本的にoptionalな要素を持つオブジェクトを、一部だけ必須にしたいと思った。
型を作るだけならシンプルだが、型チェックを入れようとするとパズルっぽくなってしまったのでメモ。
準備と目的
今回用意するのは以下のオブジェクトと3つの関数。
いい感じの名前が思いつかなかったので、適当に作った。
interface TestObject {
require1: string;
require2: number;
option1?: string;
option2?: string;
option3?: number;
}
// option1があるときに呼ばれる
function testFunction1 (obj: TestObject) : void;
// option2があるときに呼ばれる
function testFunction2 (obj: TestObject) : void;
// option1とoption3があるときに呼ばれる
function testFunction3 (obj: TestObject) : void;
とても抽象的な命名。
簡単に状況説明。
TestObject
は、今回のターゲットとなるinterfaceで、必須項目が2つとオプション項目が3つある。
オプションが追加されるかどうかはランダムで、事前に把握することはできないとする。
testFunction
1から3は、TestObject
を使って何らかの処理を実行する関数。
見かけは同じだが、optionが存在するかどうかによって呼び出されたり呼び出されなかったりする。
ということで、これを具体的な関数に起こしてみる。
function testFunction1(obj: TestObject): void {
if (obj.option1 !== undefined) {
console.log(obj.option1); // 何かしらの処理
}
}
function testFunction2(obj: TestObject): void {
if (obj.option2 !== undefined) {
console.log(obj.option2); // 何かしらの処理
}
}
function testFunction3(obj: TestObject): void {
if (obj.option1 !== undefined && obj.option3 !== undefined) {
console.log(obj.option1); // 何かしらの処理
console.log(obj.option3); // 何かしらの処理
}
}
function main() {
const data: TestObject = DataInstance();
// data例:
// {
// require1: 'string',
// require2: 123,
// option1: 'string',
// option2: 'string',
// option3: 123,
// }
if (data.option1 !== undefined) {
testFunction1(data);
}
if (data.option2 !== undefined) {
testFunction2(data);
}
if (data.option1 !== undefined && data.option3 !== undefined) {
testFunction3(data);
}
}
testFunction
は例として出力だけしているが、本来は何かしらの処理を行なっている。
・・・お気付きかもしれないが、これで動く。特に問題ない。
ただ、main
とtestFunction
で同じ条件比較を行なっている。
これは少しコストがかかるので、事前に必要とわかっている要素はあらかじめ必須にしてしまおうというのが今回の目的。
要素の一部を必須にする
ということで、現在の引数obj: TestObject
のTestObject
を、必要な要素を必須にしておけば、関数内でチェックする必要がなくなる。
調べてみる。
参考サイト:TypeScriptにおいて、とある型の一部のプロパティのみを必須にする型エイリアスを定義する - mongolyyのブログ
こちらのサイトに、期待通りの型定義があったので、お借りすることにする。
type SomeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
OmitがTから要素Kを除外するユーティリティ型で、
PickがTから要素Kだけを取り出すユーティリティ型。
Requiredがオプション要素を必須要素に書き換えるユーティリティ型で、
&でその両方を必要とする型を生成する。
つまり、
type TestObject_1 = SomeRequired<TestObject, 'option1'>;
type TestObject_2 = SomeRequired<TestObject, 'option2'>;
type TestObject_13 = SomeRequired<TestObject, 'option1' | 'option3'>;
とすることで、先ほどの関数で必要な要素を事前に必須にした型にすることができる。
よって、関数内ではundefinedチェックをすることなく、それぞれの要素を使うことが出来るようになる。
function testFunction1(obj: TestObject_1): void {
console.log(obj.option1); // 何かしらの処理
}
function testFunction2(obj: TestObject_2): void {
console.log(obj.option2); // 何かしらの処理
}
function testFunction3(obj: TestObject_13): void {
console.log(obj.option1); // 何かしらの処理
console.log(obj.option3); // 何かしらの処理
}
関数を呼び出せない
これで完成かと思えば、そんなこともない。
if (data.option1 !== undefined) {
testFunction1(data); // ts(2345)
}
/*
型 'TestObject' の引数を型 'TestObject_1' のパラメーターに割り当てることはできません。
型 'TestObject' を型 'Required<Pick<TestObject, "option1">>' に割り当てることはできません。
プロパティ 'option1' の型に互換性がありません。
型 'string | undefined' を型 'string' に割り当てることはできません。
型 'undefined' を型 'string' に割り当てることはできません。ts(2345)
*/
data.option1 !== undefined
で比較しているにも関わらず、その内部でもundefinedの型が排除されていないことが原因。
排除する方法は知らない(存在しているのだろうか…?)。
と言うことで、事前に型変換をする必要がある。
asを使用してもいいが、asは強制変換のため、極力避けたい。
そこで、今回はisを使用する
isを使って関数呼び出しを試みる
function isRequireOption<T extends keyof TestObject>(
test: TestObject,
key: T,
): test is SomeRequired<TestObject, T> { // ts(2677)
return test[key] !== undefined;
}
エラーのことは一旦さておき、この関数の概要を説明。
TestObject
型のtest
にkey
が存在していたら、test
をSomeRequired<TestObject, T>
(Tは引数key)に型変換する。
見かけ上問題ない気もするが、ここでもエラーが発生する。
エラーの内容はこちら。
/*
type 述語の型はそのパラメーターの型に割り当て可能である必要があります。
型 'SomeRequired<TestObject, T>' には 型 'TestObject' からの次のプロパティがありません: require1, require2
*/
簡単に言えば、変換後の型SomeRequired<TestObject, T>
には、元々必須だったrequire1, require2
が存在しないとのこと。なんで?
…言っても仕方ないので、解決方法を探す
原因究明
とりあえず、原因っぽいものを特定してみる。
Tをoption限定にする
function isRequireOption<T extends 'option1' | 'option2' | 'option3'>(
特に変わらず。どうやら必須オプションが確実に残るかはここでは判定していないらしい。
一旦Omitだけにしてみる
): test is Omit<TestObject, T> { // 4行目
本来なら結果が変わってしまうのだが、今はテストとして書き換えてみる。
ちなみにこの場合、意味としては引数keyとして与えられた要素が消えた型が生成されるが、
これでも上記のエラーが表示されるため、どうやらOmitが原因なのでは?
Omitを調べてみる
参考サイト:TypeScriptのOmitの実装を見てみよう
Omitは他のユーティリティ型を使用して作成されているため、元々のコードに戻すことができるらしい。
実際、VSCodeで「定義に移動」をすると、どう書かれているかをみることができる。
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
なるほど、any。もしかしてこれが悪さをしている説。
と言うことで試してみる。
OmitStrictを試してみる
先ほどの参考サイトに、any部分を限定化する方法が記述されていたので、一度試してみる。
type OmitStrict<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
function isRequireOption<T extends keyof TestObject>( // TestObject = 'option1' | 'option2' | 'option3' でも同じ
test: TestObject,
key: T,
): test is OmitStrict<TestObject, T> { // 変更
return test[key] !== undefined;
}
・・・かわりませんでした。振り出しです。
しばらく試してみる
この辺やったことのメモです。
// 1
): test is OmitStrict<TestObject, T> { // 変更前。オリジナルを使用してみる
): test is Pick<TestObject, Exclude<keyof TestObject, T>> { // 変更後。変わらず
// 2
): test is Pick<TestObject, Exclude<keyof TestObject, T>> { // 1の
): test is Pick<TestObject, 'require1' | 'require2'> { // 必須オプションを右側に持ってくる。これは通る
// つまりExclude<keyof TestObject, T>に'require1' | 'require2'が含まれることが自明であれば通る?
-?を使う
必須要素だけを取得できれば解決できそうだと思い色々当たっていたところ、-?
なるものを見つけた。
これは、省略可能な要素から省略可能という意味を消す、といった動きをするらしく、これを使えばそもそもOmitなんてしなくてもいいのでは?
ということで試す。
type SomeRequired<T, K extends keyof T> = T & {
[P in K]-?: T[P];
};
Tと、Kを必須要素に変えたオブジェクトを交差型によって結合したもの。
例えばSomeRequired<TestObject, 'option1'>
だと、option1
が(string) & (string | undefined)
となり、string
型に絞ることができる。
しかもこれでisRequireOptionのエラーが消えた。これでいいんかい。
isRequireOptionを改良する
このままでは、必須化したいオプションが2つ以上ある時に使いづらいので、それに対応させる。
function isRequireOption<T extends keyof TestObject>(
test: TestObject,
...key: T[]
): test is SomeRequired<TestObject, T> {
for (let i = 0; i < key.length; i += 1) {
if (test[key[i]] === undefined) {
return false;
}
}
return true;
}
余談:for...ofを使わないのはeslintのせい
これで、本来やりたかったことが無事できるようになった。
全体コード
ということで、main関数も含めて書き直したコードがこちら。
interface TestObject {
require1: string;
require2: number;
option1?: string;
option2?: string;
option3?: number;
}
type SomeRequired<T, K extends keyof T> = T & {
[P in K]-?: T[P];
};
function isRequireOption<T extends keyof TestObject>(
test: TestObject,
...key: T[]
): test is SomeRequired<TestObject, T> {
for (let i = 0; i < key.length; i += 1) {
if (test[key[i]] === undefined) {
return false;
}
}
return true;
}
type TestObject_1 = SomeRequired<TestObject, 'option1'>;
type TestObject_2 = SomeRequired<TestObject, 'option2'>;
type TestObject_13 = SomeRequired<TestObject, 'option1' | 'option3'>;
function testFunction1(obj: TestObject_1): void {
console.log(obj.option1); // 何かしらの処理
}
function testFunction2(obj: TestObject_2): void {
console.log(obj.option2); // 何かしらの処理
}
function testFunction3(obj: TestObject_13): void {
console.log(obj.option1); // 何かしらの処理
console.log(obj.option3); // 何かしらの処理
}
function main() {
const data: TestObject = {
require1: 'string',
require2: 123,
option1: 'string',
option2: 'string',
option3: 123,
};
if (isRequireOption(data, 'option1')) {
testFunction1(data);
}
if (isRequireOption(data, 'option2')) {
testFunction2(data);
}
if (isRequireOption(data, 'option1', 'option3')) {
testFunction3(data);
}
}
main();
isRequireOption
を使えば素のundefinedチェックをする必要がなくなるので、関数に任せる。
これで、無事mainの中で必須要素を必須化して関数呼び出しすることができた。
今更だが、console.log()
だとundefinedでもエラー出ないやん。
終わりに
最後のコードだけ見れば思ったより型パズルしてない。
ただ色んな型を調べれたので結果的には良かったのかもしれない。。