typescript

Q: TypeScript を使っているのに関数の引数のオブジェクトや配列に `readonly` を付与しないのは犯罪ですか? #Shorts

質問来てた👉 Q: TypeScript を使っているのに関数の引数のオブジェクトや配列に `readonly` を付与しないのは犯罪ですか? 結論:犯罪になる場合がある。 まず、配列の引数に `readonly` を付与しておけば以下の利点を得られます。 - うっかり関数の内部で引数の値を変更してしまうコードを書いてしまったときにコンパイルエラーが得られる - 関数の利用者が安心して関数を呼び出せる

質問来てた👉。

Q: TypeScript を使っているのに関数の引数のオブジェクトや配列に readonly を付与しないのは犯罪ですか?

結論:犯罪になる場合がある。

まず、配列の引数に readonly を付与しておけば以下の利点を得られます。

  • うっかり関数の内部で引数の値を変更してしまうコードを書いてしまったときにコンパイルエラーが得られる
  • 関数の利用者が安心して関数を呼び出せる
const getTop3 = (users: readonly User[]) => {
    const sortedUsers = users.sort((a, b) => b.score - a.score) // Property 'sort' does not exist on type 'readonly User[]'.
    return sortedUsers.slice(0, 3)
}

特に、関数の利用者視点では引数として渡したオブジェクトが変更されないことが保証されているので安心して利用できるという点が大きいでしょう。

逆に言えば readonly ではない配列やオブジェクトを利用する場合はコピーを生成してから関数を利用する間に関数の実装を確認しなければいけないなど関数の利用者に負担を強いることになってしまします。

そのため関数の引数のオブジェクトや配列二¥に readonly を付与しないのは副作用のある関数を定義してしまった罪 22 条によって犯罪となります。

ほかにも知りたいことがあったらコメント欄で教えて👇。

readonly とは

JavaScript では変数を宣言するときに const を用いるとその変数に対して再代入をできなくなります。一般的にソースコードを読む際には値が変わらないことが保証されていると読み手の負荷が下がるので基本的に const で変数を宣言することが推奨されています。

しかし const で変数を宣言しても次のようにオブジェクトのプロパティを書き換えたり、配列の要素を増減させることはできてしまいます。

const user = {
    id: 1,
    name: 'alice'
}
 
user.id = 10
 
console.log(user)
// {
//   "id": 10,
//   "name": "alice"
// } 
 
const colors = ['red', 'green', 'blue']
 
colors.push('yellow')
 
console.log(colors) // ["red", "green", "blue", "yellow"] 

TypeScript においてオブジェクトやを完全に読み取り専用にしたい場合にはインターフェイス上のプロパティに対して readonly を付与することできます。 readonly が付与されたプロパティの値を変更しようとするコンパイルエラーを得られます。

interface User {
    readonly id: number
    readonly name: string
}
 
const user: User = {
    id: 1,
    name: 'alice'
}
 
user.id = 10 // Cannot assign to 'id' because it is a read-only property.

配列の場合も同様に readonly を付与することで値の上書きだけでなく pushsort といった破壊的なメソッドの呼び出しについてもコンパイルエラーを得ることができます。

const colors: readonly string[] = ['red', 'green', 'blue']
 
colors.push('yellow')

関数の引数にオブジェクトや配列を渡すことの危うさ

ところで JavaScript において関数の引数にオブジェクトや配列を渡すときには 1 つの危険が発生する可能性があります。関数の引数として渡されたオブジェクトや関数を変更すると呼び出し元も変更されてしまいます。

interface User {
    id: number
    name: string
}
 
const user: User = { id: 1, name: 'alice' }
 
const someDangerousFunc = (argUser: User) => {
    argUser.id = 99999999
}
 
someDangerousFunc(user)
 
console.log(user)
// {
//   "id": 99999999,
//   "name": "alice"
// } 

基本的には引数のオブジェクトや配列の中身を変更しないように気をつければいいわけなのですが、 JavaScript の配列のメソッドは破壊的なものと非破壊的なものと一見区別がつかないのでその気がなくともうっかり変更してしまう可能性があります。

例えば array#sort はメソッド名から配列の並べ替えを行うメソッドですがその名前からは元の配列を変更するかしないのかわかりません。

interface User {
    id: number
    name: string
    score: number
}
 
const users: User[] = [
    {
        id: 1,
        name: 'Aice',
        score: 88
    },
    {
        id: 2,
        name: 'Bob',
        score: 56
    },
    {
        id: 3,
        name: 'Carol',
        score: 91
    },
    {
        id: 4,
        name: 'Dave',
        score: 79
    },
    {
        id: 5,
        name: 'Eve',
        score: 63
    },
]
 
const getTop3 = (argsUsers: User[]) => {
    const sortedUsers = argsUsers.sort((a, b) => b.score - a.score)
    return sortedUsers.slice(0, 3)
}
 
getTop3(users)
 
console.log(users)
 
[LOG]: [{
  "id": 3,
  "name": "Carol",
  "score": 91
}, {
  "id": 1,
  "name": "Aice",
  "score": 88
}, {
  "id": 4,
  "name": "Dave",
  "score": 79
}, {
  "id": 5,
  "name": "Eve",
  "score": 63
}, {
  "id": 2,
  "name": "Bob",
  "score": 56
}] 

見てのとおり実際に array#sort は元の配列を変更するメソッドなので引数に渡して元の変数の配列に思わぬ変更を与えてしまいます。

readonly で身を守る

上記の危険を回避するために、配列の引数の型を User[] から readonly User[] に変更しましょう。前述のとおり readonly を付与した配列に対して破壊的なメソッドを呼び出すとコンパイルエラーが得られます。

User[] 型は readonly User[] 型のサブタイプなので問題なく代入をできます。

const users: User[] = [/** 省略 **/]
 
const getTop3 = (users: readonly User[]) => {
    const sortedUsers = users.sort((a, b) => b.score - a.score) // Property 'sort' does not exist on type 'readonly User[]'.
    return sortedUsers.slice(0, 3)
}
 
getTop3(users)

上記コンパイルエラーを解決するには配列のコピーを作成してから sort メソッドを呼び出すと良いでしょう。

const getTop3 = (users: readonly User[]) => {
    const sortedUsers = [...users].sort((a, b) => b.score - a.score)
    return sortedUsers.slice(0, 3)
}

ちなみに上記実装にはまだ抜け穴が残っています。 User オブジェクトが readonly になっていないのでまだ変更可能になってしまっています。

const getTop3 = (users: readonly User[]) => {
    users[1].score = 99999999 // Bobの点数をこっそり書き換える
    const sortedUsers = [...users].sort((a, b) => b.score - a.score)
    return sortedUsers.slice(0, 3)
}

抜け穴を塞ぐためには User インターフェイスのプロパティをすべて readonly にした新たなインターフェイスを用意して引数の型として利用しましょう。

interface User {
    id: number
    name: string
    score: number
}
 
interface ReadonlyUser {
    readonly id: number
    readonly name: string
    readonly score: number
}
 
const users: User[] = [/** 省略 **/]
 
const getTop3 = (users: readonly ReadonlyUser[]) => {
    users[1].score = 99999999 // Cannot assign to 'score' because it is a read-only property.
    const sortedUsers = [...users].sort((a, b) => b.score - a.score)
    return sortedUsers.slice(0, 3)
}

実際にはわざわざ同じようなプロパティを持つインターフェイスを二重に定義するのは面倒なので組み込み型である Readonly<T> を使いましょう。

const getTop3 = (users: readonly Readonly<User>[]) => {
    users[1].score = 99999999 // Cannot assign to 'score' because it is a read-only property.
    const sortedUsers = [...users].sort((a, b) => b.score - a.score)
    return sortedUsers.slice(0, 3)
}

Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事