定数から生成した型が string になった!? 焦らずアサーション(Assertion)を付けよう
🌼 はじめに
プロジェクトに参画したばかりの頃、定数(主にオブジェクト)から生成した型がstring
になってて戸惑ったことがありました。解決できた後にも何回か同じミスを繰り返し…^_^ もうstring
から卒業したいので今回のテーマにします。
Assertion は「主張」、「断言」という意味の英単語であり、typescript では型を断言して上書きする意味で使われてます。この記事では Assertion を使ってstring
をもっと厳密な型に絞る方法を話したいと思います。
string
になった
1. 型推論で1-1. 問題発生
1番あるあるのケースから見ていきましょう。
const SCREEN_NAMES = {
HOME: 'home',
SEARCH: 'search',
HISTORY: 'history',
HELP: 'help',
}
// Expected: 'search'
type Search = typeof SCREEN_NAMES.SEARCH // string
// Expected: 'search' | 'help'
type SearchAndHelp = typeof SCREEN_NAMES.SEARCH | typeof SCREEN_NAMES.HELP // string
// Expected: ("home" | "search" | "history" | "help")[]
const ScreenNames = Object.values(SCREEN_NAMES) // string[]
特定バリューから型を生成してるのにstring
になってるし、Object.values()
で生成した配列もstring[]
で推論されてますね。
このままだと世の中に存在するすべての文字列を許容されてしまうので安全ではありません。なぜこうなってるのか原因から解説します。
1-2. 原因
原因はとても簡単です。
typescript がSCREEN_NAMES
の型を推論するとき、バリューを全部string
にしてるからです。
// SCREEN_NAMES の型推論
const SCREEN_NAMES: {
HOME: string;
SEARCH: string;
HISTORY: string;
HELP: string;
}
string
にしてる理由はオブジェクトのバリューが変更される可能性があるので、ゆるい型で対応してるのではないかと思います。
SCREEN_NAMES.SEARCH = 'help'
console.log(SCREEN_NAMES.SEARCH) // `search` → 'help' に変更される
だったら typescript に「こいつらは絶対変更されないよ」と教えるとstring
じゃなくリテラル型に推論させてくれるんじゃないでしょうか!
Const Assertions
1-3. 解決:Const Assertions とは
Const Assertions は「この値は変わることのない固定値」だと typescript に断言することです。もっと具体的には以下の3つができます。
- リテラル型が拡張されない(ex.
"hello"
がstring
にならない ) - オブジェクトの全プロパティが
readonly
になる - 配列が
readonly
のタプルになる
理解のためにサンプルコード見てみましょう。
// プリミティブ型
let number = 5 // number
let string = "hello" // string
const constNumber = 5 // 5
const constString = "hello" // "hello"
// オブジェクト
let array = [10, 20] // number[]
let object = { text: "hello" } // { text: string; }
const constArray = [10, 20] // number[]
const constObject = { text: "hello" } // { text: string; }
プリミティブ型はconst
で宣言するだけでリテラル型に推論されましたが、オブジェクトはそうはいかなかったです。理由は先程話した通り値が修正される可能性があるからです。(配列もpush
などのメソッドで修正できる)
オブジェクトの値を固定するためには宣言時にas const
をつけましょう。
const constArray = [10, 20] as const; // readonly [10, 20]
const constObject = { text: "hello" } as const; // { readonly text: "hello"; }
これでオブジェクトも全部readonly
になり、修正できなくなるので安全かつ推論もリテラル型にできます。
+) ちなみにlet
宣言にもas const
つけたら固定できますが、そうする理由がないのでやりません^_^。const
宣言 + as const
で固く固定しておきましょう。
let string = "hello" as const; // "hello"
let array = [10, 20] as const; // readonly [10, 20]
let object = { text: "hello" } as const; // { readonly text: "hello"; }
実戦
SCREEN_NAMES
にas const
つけてあげましょう。
const SCREEN_NAMES = {
HOME: 'home',
SEARCH: 'search',
HISTORY: 'history',
HELP: 'help',
} as const
type SearchScreen = typeof SCREEN_NAMES.SEARCH // "search"
type SearchAndHelp = typeof SCREEN_NAMES.SEARCH | typeof SCREEN_NAMES.HELP // "search" | "help"
const ScreenNames = Object.values(SCREEN_NAMES) // ("home" | "search" | "history" | "help")[]
幸せになりました“ヽ(´▽`)ノ”
Object.keys()
でstring
になった
2. 2-1. 問題発生
大体の場合はas const
つけることで解決できますが、例外があります。Object.keys()
を使うときです。
const SCREEN_NAMES = {
HOME: 'home',
SEARCH: 'search',
HISTORY: 'history',
HELP: 'help',
} as const
// Expected: ("HOME" | "SEARCH" | "HISTORY" | "HELP")[]
const ScreenNames = Object.keys(SCREEN_NAMES) // string[]
ちゃんとas const
つけてるのになぜかまた配列がstring[]
になってます。なんででしょうかね…これも原因から解説します。
2-2. 原因
原因はObject.keys()
返り値は必ずstring[]
になるようにシステムされてるからです。
なんで?だと思って色々調べたところ、同じ問題を扱ってるissueで Mohamed Hegazy さん(typescript チームのエンジニア)の説明を見つけました。
This is intentional. Types in TS are open ended. So keysof will likely be less than all properties you would get at runtime.
Object.keys()
がstring[]
を返すのは意図的(intentional)と言ってます。でもなんでそうしてるのかはこれだけ見てもよくわからない気がします。
もうちょっと探してみたら関連する他のissueで Anders Hejlsberg さん(typescript チームのエンジニア)が説明してくれてました。
an object can (and often does) have more properties at run-time than are statically known at compile time. For example, imagine a type
Base
with just a few properties and a family of types derived from that. CallingObject.keys
with aBase
would return a(keyof Base)[]
which is almost certainly wrong because an actual instance would be a derived object with more keys. It completely degenerates for type{}
which could be any object but would returnnever[]
for its keys.
Object.keys()
が(keyof T)[]
を返すようになった場合の懸念点を説明してくれてます。
- オブジェクトはコンパイル時よりランタイム時に多くのプロパティを持つ可能性があるし実際よくそうしてるから間違ってる型になりうる
-
{}
はnever[]
を返すようになってしまう
ざっくりまとめるとかんな感じでしょう。(賛同しない人も多いようですが笑)
ちょっと気になってlib.es2015.core.d.ts
からObject.keys()
メソッドの型を探ってみると、本当にstring[]
を返すように設定されてました。
interface ObjectConstructor {
//...
/**
* Returns the names of the enumerable string properties and methods of an object.
* @param o Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
*/
keys(o: {}): string[];
//...
}
typescript チームが意図的にやってるからstring[]
が返ってくること自体は仕方ないですね。こういう場合は厳密な型に上書きする必要があります。
Type Assertions
2-3. 解決:Type Assertions とは
Type Assertions は「この値は特定の型(type)」だと typescript に断言することです。
サンプルコード見てみましょう。
// 型を厳密にする
const array = [] // any[]
const array_asserted = [] as number[] // number[]
const object = {} // {}
const object_asserted = {} as { text: string } // {text: string}
[]
とか{}
は推論できる材料がなにもないためany[]
になります。こういう場合 Type Assertions でもっと厳密な型に上書きできます。
// 型をゆるくする
const array2 = [1, 2, 3] // number[]
const array2_asserted = [1, 2, 3] as (number | string)[] // (number | string)[]
逆に型をゆるくすることもできます。[1, 2, 3]
が今はnumber
だけの配列になってるけど、今後string
が入る可能性がある場合はちょっとゆるい型をつけておくことができます。
注意点は、Type Assertions はもっと厳密な型もしくはゆるい型に上書きすることはできても完全に違う型に上書きすることはできないということです。
const xfdsf = "hello" as number
// Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other.
// If this was intentional, convert the expression to 'unknown' first.(2352)
これを使ってstring
をリテラル型に上書きしましょう。
実戦
const SCREEN_NAMES = {
HOME: 'home',
SEARCH: 'search',
HISTORY: 'history',
HELP: 'help',
} as const
type ScreenNameKeys = keyof typeof SCREEN_NAMES // "HOME" | "SEARCH" | "HISTORY" | "HELP"
const ScreenNames = Object.keys(SCREEN_NAMES) as ScreenNameKeys[] // ("HOME" | "SEARCH" | "HISTORY" | "HELP")[]
keyof typeof SCREEN_NAMES
でユニオン型を生成してから配列型にしました。これでちゃんと厳密な型に上書きされましたね。
2-4. ついでに
2-2で調査した理由と同じ理由でObject.entries()
もキー部分はstring
で返します。
const ScreenNamePairs = Object.entries(SCREEN_NAMES) // [string, "home" | "search" | "history" | "help"][]
厳密にするなら Type Assertions を使う必要があります。as
で型付けしましょう。
🌷 終わり
この記事を書いた1番の理由は私が定数宣言する時かなり高い確率でas const
つけるのを忘れるからでした。チームメンバーたちに「as const
つけて」というレビューを何回もさせてます。ごめんなさい…いつもありがとう…もう忘れないよ(´・ω・`)
Discussion