💉

定数から生成した型が string になった!? 焦らずアサーション(Assertion)を付けよう

2021/12/23に公開

🌼 はじめに

プロジェクトに参画したばかりの頃、定数(主にオブジェクト)から生成した型がstringになってて戸惑ったことがありました。解決できた後にも何回か同じミスを繰り返し…^_^ もうstringから卒業したいので今回のテーマにします。

Assertion は「主張」、「断言」という意味の英単語であり、typescript では型を断言して上書きする意味で使われてます。この記事では Assertion を使ってstringをもっと厳密な型に絞る方法を話したいと思います。

1. 型推論でstringになった

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じゃなくリテラル型に推論させてくれるんじゃないでしょうか!

1-3. 解決:Const Assertions

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_NAMESas 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")[]

幸せになりました“ヽ(´▽`)ノ”

2. Object.keys()stringになった

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. Calling Object.keys with a Base 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 return never[] 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[]が返ってくること自体は仕方ないですね。こういう場合は厳密な型に上書きする必要があります。

2-3. 解決:Type Assertions

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つけて」というレビューを何回もさせてます。ごめんなさい…いつもありがとう…もう忘れないよ(´・ω・`)

GitHubで編集を提案

Discussion