背景
TypeScriptでコーディングしていたとき、私のType力が足りないがために型エラーがなかなか解決できず、悪戦苦闘したのでその記録を残します。
はじめに
用意していた型は以下のようなもの。特別なものは何もない、よくある型だと思います。
特定の文字列に特定の型を紐づけたいというシンプルな願いです。
interface Hoge {
string: string;
stringarr: string[];
number: number;
};
Hoge
のいくつかのキーついて、currentValue
に該当キーあればその値を、そうでなければdefaultValue
の値をvalue
にもつ新しいオブジェクトを作りたい、というのが今回の目的です。
それぞれこのような値にしました。
const defaultValue: Hoge = {
string: 'default',
stringarr: [],
number: 0,
};
const currentValue: Partial<Hoge> = {
stringarr: ['one'],
number: 100,
};
const keysToKeep: (keyof Hoge)[] = ['string', 'number'];
この場合、目指すオブジェクトはこのようになりますね。
// これを動的に作りたい
const newObject: Partial<Hoge> = {
string: 'default',
number: 100,
};
失敗①
初心者エンジニアの自分が最初に書いたのはこちら。
const newObject1: Partial<Hoge> = {};
keysToKeep.forEach((key: keyof Hoge) => {
//Type 'string | number | string[]' is not assignable to type 'undefined'.
newObject1[key] = currentValue[key] ?? defaultValue[key];
});
ん…?何がいけないのでしょう?
どうやらnewobject[key]
の型とcurrentValue[key] ?? defaultValue[key]
の型が一致しないと言われている様子。
失敗②
最初の方法で特に問題なく入れられるはずだと思ったのですが、書き方が悪く自分の想像していた型と異なる形で解釈されてしまったのかもしれません。
確かめるため、newObject
のキーごとに条件分岐させてみました。
const newObject2: Partial<Hoge> = {};
keysToKeep.forEach((key: keyof Hoge) => {
// これは問題ない
switch (key) {
case 'string':
newObject2[key] = currentValue[key] ?? defaultValue[key];
break;
case 'stringarr':
newObject2[key] = currentValue[key] ?? defaultValue[key];
break;
case 'number':
newObject2[key] = currentValue[key] ?? defaultValue[key];
break;
default:
break;
};
// これは型エラー
switch (key) {
case 'string':
case 'stringarr':
case 'number':
// Type 'string | number | string[]' is not assignable to type 'undefined'.
newObject2[key] = currentValue[key] ?? defaultValue[key];
break;
default:
break;
};
});
どうやらnewObject[key]
の型ごとに条件分岐させないとエラーになるらしいですね。
これは@ts-ignore
を使っても良いのではないか…という誘惑にかられたとき、このQiita記事のリンクが送られてきました。
......もうしばらく解決する方法を模索していきますか。
失敗③
newObject
を{}
で初期化しているから、最初newobject[key]
はどんなkey
に対してもundefined
です。
そこにtype 'string | number | string[]'
である値を入れようとしているわけだからいけないのだと考えました。
{}
なのがいけないのだとしたら、それを変えれば良いではありませんか!
defaultValue
を初期値とし、そこにcurrentValue
の値を入れていき、最後にkeysToKeep
に入っていないkey
をdelete
していけばうまく解釈できるようになるかも…
const newObject3: Partial<Hoge> = structuredClone(defaultValue);
keysToKeep.forEach((key: keyof Hoge) => {
if (currentValue[key]) {
// Type 'string | number | string[]' is not assignable to type 'undefined'.
newObject3[key] = currentValue[key] ?? defaultValue[key];
}
});
初期・現在の値は関係なくtype 'undefined'
と言われてしまいました。
失敗④
となると、newObject
がPartial
型で、value
がundefined
の可能性があるからダメなのでしょうか?
その場合は仕方がないですが、Partial<Hoge>
を諦めてすべてのキーがあるHoge
型に変更しましょうかね。
const newObject4: Hoge = structuredClone(defaultValue);
keysToKeep.forEach((key: keyof Hoge) => {
if (currentValue[key]) {
// Type 'string | number | string[]' is not assignable to type 'never'.
newObject4[key] = currentValue[key] ?? defaultValue[key];
}
});
undefined
ではなくなりましたが、never
になってしまいました。どうやらこのようにしてダメなようです。
失敗⑤
newObject
を宣言する段階で目当ての値を入れてしまえばよいのでは、と作成したのがこちら。
const newObject5: Partial<Hoge> = keysToKeep.reduce((acc, key) => {
// Type 'string | number | string[]' is not assignable to type 'undefined'.
acc[key] = currentValue[key] ?? defaultValue[key];
return acc
}, {} as Partial<Hoge>);
残念ながら失敗①とエラーの様子は変わらず。
成功①
Object[key]
に代入する形が良くないのではとスプレッド構文にマイナーチェンジしてみました。
const newObject6: Partial<Hoge> = keysToKeep.reduce((acc, key) => {
return { ...acc, [key]: currentValue[key] ?? defaultValue[key] };
}, {});
これはなんと問題ありませんでした。要素を追加していくだけであれば大丈夫な様子。
ただ、スプレッド構文はメモリ効率上あまりよろしくないので、このような使用方法は避けたい気もします。(そもそもforEachがよろしくないというツッコミはさておき)
失敗⑥
いっそのことnewObject
の型をPartial<Hoge>
から変えれば楽になるのではないかと考えてみました。
type Partial<Record<keyof Hoge,Hoge[keyof Hoge]>>;
ただしこうするとnewObject['string']=1
などでもエラーが出てこなくなってしまいました。わざわざここまで苦労して型を縛っていた意味がなくなるため却下。
成功②
キーが動的に指定されるのが悪かったんだ!とfiltername
とfiltervalue
という固定のキーを持つオブジェクトに入れてみたらほしい情報がすんなり手に入りました。
interface Hoge {
string: string;
stringarr: string[];
number: number;
};
// 新しい型
type HogeObjArr<K extends keyof Hoge> = {
[T in K]: { fieldname: T; fieldvalue: Hoge[T]; }
}[K][];
const defaultValue: HogeObjArr<keyof Hoge> = [
{ fieldname: 'string', fieldvalue: "default" },
{ fieldname: 'stringarr', fieldvalue: [] },
{ fieldname: 'number', fieldvalue: 0 }
];
const currentValue: HogeObjArr<keyof Hoge> = [
{ fieldname: 'stringarr', fieldvalue: ['one'] },
{ fieldname: 'number', fieldvalue: 100 }
];
const keysToKeep: Field[] = ['string', 'number'];
const newObject7: HogeObjArr<keyof Hoge> = keysToKeep.map((key) => {
const current = currentValue.find((field) => field.fieldname === key);
return current ?? defaultValue.find((field) => field.fieldname === key)!;
});
/*
結果こうなる
newObject7=[
{ fieldname: 'string', fieldvalue: "default" },
{ fieldname: 'number', fieldvalue: 100 }
]
*/
必要以上に冗長に見えます。
それに、抜けているor重複しているfieldname
がないかの確認ができないのは残念です(型を追求したらもっとうまく制限できるかも?)
成功③失敗⑦
成功②で諦めた翌日、GithubCopilotの利用が解禁されたので使ってみたところ…
const newObject8: Partial<Hoge> = Object.fromEntries(
keysToKeep.map((key) => [key, currentValue[key] ?? defaultValue[key]])
);
これです!!これですよ求めていたのは!!!
ちなみにこれは1から作成させたもので、失敗①パターンから改善させる方向性で指示しても全くダメでした。
……と思ったらPartial の部分を適当な型に変えても通ってしまうと教えていただきました。Object.fromEntriesの戻り値はRecordなので、as 使って型を黙らせているのと変わらないんですね。残念。
相談してみた①
行き詰っていたとき周囲に相談したところいくつかアドバイスをいただきましたので、そちらもご紹介します。
まずはPartial
ではなくOmit
を利用するのはどうか?というもの。足りないキーが何になるか判明している場合は良いかもしれません。
const newObject9: Omit<Hoge, "stringarr"> = Object.fromEntries(
keysToKeep.map((key) => [key, currentValue[key] ?? defaultValue[key]])
) as Omit<Hoge, "stringarr">;
相談してみた②
そもそもPartial
の利用をやめようよパート2。
null
を許可することによって、データが格納されていない判定ができれば事足りるのではないかという案です。
そしてべつにループ処理の必要性ないのではという。
interface Hoge {
string: string | null;
stringarr: string[] | null;
number: number | null;
};
const defaultValue: Hoge = {
string: 'default',
stringarr: [],
number: 0,
};
const currentValue: Hoge = {
string: null,
stringarr: ['one'],
number: 100,
};
const newObject10: Hoge = {
string: currentValue.string ?? defaultValue.string,
stringarr: currentValue.stringarr ?? defaultValue.stringarr,
number: currentValue.number ?? defaultValue.number,
};
相談してみた③
抽象化した方が型解決してくれるんですよ…と先輩からのアドバイス。操作を切り出せばなぜかOKになるらしいです。
function pickKeys<T extends Hoge, K extends keyof T>(
source: Partial<T>,
keys: K[],
defaults: T
): Partial<T> {
return keys.reduce((acc, key) => {
acc[key] = source[key] ?? defaults[key];
return acc;
}, {} as Partial<T>);
}
const newObject11 = pickKeys(currentValue, keysToKeep, defaultValue);
これはかなり悔しいですね。
さいごに
比較的シンプルな型であたる問題なので、世の中に解決方法が出回っているようにも思います。しかし調べ方が分からず有力な情報を見つけられませんでしたので、今回自力で模索を試みてみました。
安易にPartialに手を出すのはもうやめます。
自分が最初に思いついた型が適切か、TypeScriptの思想に合うか、きちんと考えなければなりませんね。
type-challengesで修行してきます。