TypeScriptのconstやas constの挙動について

概要

TypeScriptには定数を宣言する仕組みとして、constやas constが存在します。
ただ、オブジェクトや配列をconstで定義したとしても、中の値は自由に書き換えれるなどの注意点があります。
これを例とともに見ていきたいと思います。
動作環境: TS5.4.3

const

constは定数を宣言できますが、配列やオブジェクトで扱うときに注意が必要です。 数値や文字列をconstで定義して、後から置き換えようとするともちろん怒られます。

const num = 0
num = 1 // Cannot assign to 'num' because it is a constant.(2588)

一方でオブジェクトの値の置き換えは通ってしまいます。

const obj = {test: 1}
obj.test = 2 // 通る

これは、一度宣言したobj自体をまるっと書き換えているのではなく、objの存在はそのままで中のプロパティのみ書き換えているからです。
配列の場合もconstで宣言しても内部の要素は書き換え可能です。

const arr = [1, 2, 3]
arr[0] = 5

なお、オブジェクト自体を書き換えようとすると怒られます

const obj = {test: 1}
obj = {} // Cannot assign to 'obj' because it is a constant.(2588)

constの中身をreadonlyにするとどうなるか

内部のプロパティを書き換えられないように、readonlyで試しに書いてみます。 この場合、プロパティを書き換えることはできなくなります。

type place = {
    readonly value: number
    readonly name: string
}

const places: place = {
  value: 0,
  name: 'コンビニ',
}
places.name = 'スーパー' // Cannot assign to 'name' because it is a read-only property.(2540)

一方で、以下のようなreadonlyオブジェクトを持つ配列の場合は、配列操作で実質的に値を置き換えれるので注意が必要です。

type place = {
    readonly value: number
    readonly name: string
}

const places: place[] = [
    {
        value: 0,
        name: 'コンビニ',
    },
]

// 配列の要素を削除
places.pop()

// 新しい要素を入れる
places.push({
    value: 1,
    name: '学校',
});

console.log(places);
// [LOG]: [{
//   "value": 1,
//   "name": "学校"
// }] 

as const

as constは配列やオブジェクト全体をreadonlyのリテラルとして扱います。
よって内部プロパティを書き換えることはできません。

const obj = {test: 1} as const
obj.test = 2 // Cannot assign to 'test' because it is a read-only property.(2540)


const arr = [1, 2, 3] as const
arr[0] = 5 // Cannot assign to '0' because it is a read-only property.(2540)

arr.pop() // Property 'pop' does not exist on type 'readonly [1, 2, 3]'.(2339)

MySQL 8.0.18で導入されたEXPLAIN ANALYZEについて

概要

MySQL 8.0.18でEXPLAIN ANALYZEという句が導入されたのを知ったので、どんなものかメモします。
公式の説明はexplainのEXPLAIN ANALYZE による情報の取得という段落に記載されています。

以下の情報を取得できるようです。

  • 推定実行コスト
  • 戻された行の推定数
  • 最初の行を返す時間
  • すべての行 (実際のコスト) を返す時間 (ミリ秒)
  • イテレータによって返された行数
  • ループ数

実行されるコストや時間など、本番環境で実行する前に知っておきたい情報が揃ってますね。

EXPLAIN ANALYZEを実行してみる

下準備で3テーブル用意してそれぞれ1000行Insert

CREATE TABLE Users (
    UserID INT AUTO_INCREMENT PRIMARY KEY,
    UserName VARCHAR(50),
    Email VARCHAR(100) UNIQUE,
    SignUpDate DATE,
    ProfileDescription TEXT
);

CREATE TABLE Blogs (
    BlogID INT AUTO_INCREMENT PRIMARY KEY,
    UserID INT,
    Title VARCHAR(100),
    Content TEXT,
    PublishDate DATE,
    FOREIGN KEY (UserID) REFERENCES Users(UserID)
);

CREATE TABLE Comments (
    CommentID INT AUTO_INCREMENT PRIMARY KEY,
    BlogID INT,
    UserID INT,
    Comment TEXT,
    CommentDate DATE,
    FOREIGN KEY (BlogID) REFERENCES Blogs(BlogID),
    FOREIGN KEY (UserID) REFERENCES Users(UserID)
);

DELIMITER //

CREATE PROCEDURE InsertDummyData()
BEGIN
  DECLARE i INT DEFAULT 1;
  
  WHILE i <= 1000 DO
    INSERT INTO Users (UserName, Email, SignUpDate, ProfileDescription)
    VALUES (CONCAT('User', i), CONCAT('user', i, '@example.com'), CURDATE(), 'This is a sample description.');
    
    INSERT INTO Blogs (UserID, Title, Content, PublishDate)
    VALUES (i, CONCAT('Blog Post ', i), 'This is sample blog content.', CURDATE());
    
    INSERT INTO Comments (BlogID, UserID, Comment, CommentDate)
    VALUES (i, i, 'This is a sample comment.', CURDATE());
    
    SET i = i + 1;
  END WHILE;
END;

//
DELIMITER ;

CALL InsertDummyData();

インデックス有りでSQL実行

explain analyze select * from Users
inner join Blogs on Users.UserID = Blogs.UserID
inner join Comments on Comments.BlogID =  Blogs.BlogID;

インデックス有り結果

-> Nested loop inner join  (cost=802 rows=1000) (actual time=0.0995..4.66 rows=1000 loops=1)
    -> Nested loop inner join  (cost=452 rows=1000) (actual time=0.0836..2.98 rows=1000 loops=1)
        -> Filter: (comments.BlogID is not null)  (cost=102 rows=1000) (actual time=0.0588..1.03 rows=1000 loops=1)
            -> Table scan on Comments  (cost=102 rows=1000) (actual time=0.058..0.952 rows=1000 loops=1)
        -> Filter: (blogs.UserID is not null)  (cost=0.25 rows=1) (actual time=0.00163..0.00174 rows=1 loops=1000)
            -> Single-row index lookup on Blogs using PRIMARY (BlogID=comments.BlogID)  (cost=0.25 rows=1) (actual time=0.00148..0.00152 rows=1 loops=1000)
    -> Single-row index lookup on Users using PRIMARY (UserID=blogs.UserID)  (cost=0.25 rows=1) (actual time=0.00145..0.00149 rows=1 loops=1000)

インデックスが効かないjoinで実行

explain analyze select * from Users
inner join Blogs on Users.UserID = Blogs.UserID
inner join Comments on Comments.CommentDate =  Blogs.PublishDate;

インデックスが効かないjoin 結果

-> Inner hash join (comments.CommentDate = blogs.PublishDate)  (cost=100461 rows=100000) (actual time=3.27..179 rows=1e+6 loops=1)
    -> Table scan on Comments  (cost=0.0189 rows=1000) (actual time=0.00992..0.598 rows=1000 loops=1)
    -> Hash
        -> Nested loop inner join  (cost=452 rows=1000) (actual time=0.0427..2.65 rows=1000 loops=1)
            -> Filter: (blogs.UserID is not null)  (cost=102 rows=1000) (actual time=0.0286..1 rows=1000 loops=1)
                -> Table scan on Blogs  (cost=102 rows=1000) (actual time=0.0281..0.917 rows=1000 loops=1)
            -> Single-row index lookup on Users using PRIMARY (UserID=blogs.UserID)  (cost=0.25 rows=1) (actual time=0.00141..0.00144 rows=1 loops=1000)

Costが1000以下だったのが100461まで増加していますね。このように可視化して見れるのは面白いですね。

JestのExpected: ["object", "property"] Received: serializes to the same string について(オブジェクトの値検証)

概要

JestでObjectの値をtoBeで比較するとExpected: ["object", "property"] Received: serializes to the same stringというエラーが発生することがあります。
これはtoBeが厳密比較をしており、オブジェクトや配列の中身が一致していても同じとは判定されないためです。

JavaScriptやTypeScriptで以下のような比較をしても、値の一致ではなく同じメモリ上にあるオブジェクトかどうかで判定するため、一致しないと判定されることと同じ理屈です。

const data = {
  id: 1,
  name: 2,
}

const expected = {
  id: 1,
  name: 2,
}

console.log(data === expected) // false

対応策

tostrictequalを使うことでオブジェクトの値比較をすることができます。 https://jestjs.io/ja/docs/expect#tostrictequalvalue

const data = {
  id: 1,
  name: 2
};

const expected = {
  id: 1,
  name: 2
};

expect(data).toStrictEqual(expected) // true

なおtoEqualという比較関数も存在します。

https://jestjs.io/ja/docs/expect#toequalvalue

しかし、この関数はundefinedの値は評価しないため、{a: undefined, b: 2}{b: 2} は同じであると評価されます。
なのでより厳密に比較するtoStrictEqualがおすすめです。

PHPの曖昧判定を使う時に注意しておくべきこと

PHPの曖昧判定

PHPは厳密ではない比較をする関数がいくつかあります。
厳密比較をしないことによって意図しない判定をしてしまうことがあります。
今回はemptyを使った時の注意点を、実装とユニットテストの観点から見てみます。

ちなみに自分は絶対厳密比較しか許さないという派閥では全くなく、あくまで注意すべきくらいの気持ちでいます。

サンプルコードと注意点

例えば、空文字だった場合に処理したい内容を、emptyで比較するとします。
なお空文字以外で動作すると意図しない挙動になるコードと仮定します(サンプルは文字列返しているだけですが)。

実装

    public function checkEmpty(string $value)
    {
        if (empty($value)) {
            return "Value is empty";
        } else {
            return "Value is not empty";
        }
    }

テストコード

    public function test_空文字の時にValue is emptyを返すこと()
    {
        $sample = new Sample();
        $result = $sample->checkEmpty("");
        $this->assertSame($result, "Value is empty");
    }

    public function test_空文字の時にValue is not emptyを返すこと()
    {
        $sample = new Sample();
        $result = $sample->checkEmpty("a");
        $this->assertSame($result, "Value is not empty");
    }

一見このテストコードは返すパターンと返さないパターンをテストできているように見えます。
しかし、emptyは"0"もtrueを返します(PHP8.3現在)。

そのため、空文字の時のみ期待通りの動きをする、というテストが出来ていません。
なんなら実装自体も空文字の時だけ"Value is empty"を返す、という動きになっていません。

このような意図しない値の判定が生じうるケースは、if($var)などの判定でも起こり得ると思います。

エンジニアは前後のコードやこの関数がどのように呼ばれるか知っているため、"0"という値が現実的に入っていることはない、ということが分かってこのような実装をすることがあるかもしれません。実際問題ないケースがほとんどだと思います。
ただ、今後もこの関数がセーフティに使われることを保証するなら、曖昧な判定は避けたほうが好ましいと思います。

また、emptyif($var)を使う場合、判定の挙動を実装・ユニットテストで都度意識する必要があるため、実は厳密比較の方が考えることが少なくなるメリットもあると思います。

対応策

ではどうするかと言うと、なるべく厳密比較を用いるのが良いと思います。

修正したコード

    public function checkEmpty(string $value)
    {
        if ($value === "") {
            return "Value is empty";
        } else {
            return "Value is not empty";
        }
    }

上記コードは文字列を厳密比較しているので、"0"はelseに入ります。
またテストコードは先ほどのサンプルでしっかり返すパターンと返さないパターンをテスト出来ているようになりました。

曖昧にfalseyな値を判定することは便利なケースもありますし、使うべきではないとまでは思いませんが、意図しない判定にならないように注意することは大事ですね。

DBの値をフロントエンドやバックエンドで定数として扱う際のリスクと改善策

概要

今まで、DBの値をFrontendで定数として持って使うケースや、テーブル更新のコストが高いと判断してBackendで値を保持・運用するケースをたまに見てきました。
もちろん軽量なプロジェクトやプロトタイプの製作では有効な場合もありますが、保守性や変更容易性を損なってしまうこともあるので、それについて記載します。

DBの値を定数で持つ例

例えばユーザーが配送の梱包サイズをセレクトボックスなどで選択するUIがあったとします。
これを定数で運用するとどうなるか見ていきます。

フロントエンドの例

// 梱包サイズの定数(実際は定数ファイルなどで定義されていることが多いと思います)
const PACKAGE_SIZES = [
  { id: 1, name: "小" },
  { id: 2, name: "中" },
  { id: 3, name: "大" }
];

// セレクトボックスでの表示
const PackageSizeSelect = () => (
  <select>
    {PACKAGE_SIZES.map(size => (
      <option key={size.id} value={size.id}>{size.name}</option>
    ))}
  </select>
);

このプログラムの何が問題かというと、梱包サイズが増えた場合にフロントエンドの改修が必ず必要になります。
さらに、DBでidやnameが更新された時、合わせてフロントエンドも変更しないと追従できません。
もしかしたら将来、FEで定数として持っていることを知らないエンジニアが、FEを合わせて更新することを忘れてしまう可能性もあり得ます。

もちろん、追加や更新がなく、FEで持っても問題ないケースも存在すると思います。

バックエンドの例

次にバックエンドで定数でもってしまう例を考えてみます。
バックエンドでこうした実装になってしまう例としては、例えば梱包サイズにtypeを表示する要件が追加されたが、運用されているテーブルのカラム追加はコストが高いので、コードで補完しよう、というようなときだと思います(自分の経験上)。

// 梱包サイズとそのタイプをPHPで定義
$packageSizes = $stmt->fetchAll(PDO::FETCH_ASSOC);

// 各梱包サイズにタイプを動的に追加
foreach ($packageSizes as $key => $size) {
    switch ($size['id']) {
        case 1:
            $packageSizes[$key]['type'] = '軽量';
            break;
        case 2:
            $packageSizes[$key]['type'] = '標準';
            break;
        case 3:
            $packageSizes[$key]['type'] = '重量';
            break;
    }
}

// 梱包サイズとタイプをクライアントに送るAPIエンドポイントの例
$app->get('/package-sizes', function ($request, $response) use ($packageSizes) {
    return $response->withJson($packageSizes);
});

この場合、typeをテーブルの情報として持っておらず、PHPで持っているので、テーブルからは読み取れず、実装を見ないとなぜこう表示されているのか分かりません。
またtypeの追加や更新があった場合に、カラムを追加していればSQLの追加や更新だけで対応できますが、この例だと毎回PHPの修正とデプロイが必要になります。

FE、BEの改修がいらなくなるパターンを考える

ではどうすれば良いかというと、BEはテーブルの値を返すだけ、FEはBEから返す値を使うだけ、という構成にすれば良いと思います。

フロントエンド

  const [sizes, setSizes] = useState<PackageSize[]>([]);

  useEffect(() => {
    // APIから梱包サイズのデータを取得する想定
    fetch('/api/package-sizes')
      .then(response => response.json())
      .then(data => setSizes(data))
      .catch(error => console.error('Error fetching package sizes:', error));
  }, []);

  return (
    <ul>
      {sizes.map(size => (
        <li key={size.id}>
          ID: {size.id}, 名前: {size.name}, タイプ: {size.type}
        </li>
      ))}
    </ul>
  );

実装は簡略化して書いてます。
カラムが追加されて、それを新しく表示する場合は対応が必要ですが、梱包のidやname, typeが追加、更新、削除されてもFEの改修は必要がなくなりました。

サーバーサイド

$packageSizes = $stmt->fetchAll(PDO::FETCH_ASSOC);

$app->get('/package-sizes', function ($request, $response) use ($packageSizes) {
    return $response->withJson($packageSizes);
});

バックエンドも梱包のidやname, typeが追加、更新、削除されても改修は基本必要なくなりました。
もちろんこの実装は簡略化しており、更新内容によってはValueObjectの更新だったり、カラム追加などではDTOの更新などをする必要はあると思います。

総合的には毎回FEやBE対応をする必要がなくなって、SQL対応のみで良くなるケースが増え、保守性や変更容易性を損なわない仕組みになったと思います。

複合インデックスの効果と注意点

概要

インデックスには複合インデックスというものがあります。
これは単一のカラムではなく、複数のカラムを組み合わせてインデックスを貼るもので、組み合わせで検索されるカラムに貼ると効率的です。
複合インデックスを使うにあたって、いくつか注意点があるのでまとめてみました。

参考

MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.3.6 マルチカラムインデックス

複合インデックスの働き

以下のテーブルを用意します。

CREATE TABLE Users (
    UserID INT AUTO_INCREMENT PRIMARY KEY,
    Email VARCHAR(255) NOT NULL,
    Name VARCHAR(100) NOT NULL,
    Age INT,
    Gender VARCHAR(10),
    Status VARCHAR(50),
    UNIQUE(Email),
    INDEX Email_IDX (Email),
    INDEX Name_Status_IDX (Name, Status)
);

複合インデックスはNameとStatusに貼っています(この組み合わせの検索が多いと仮定)。
この時、どのような検索でインデックスが働くか見てみます。実行環境はMySQL8.0です。

SQL

keyが使用されているか確認します。

NameをWhere句に指定

explain select * from Users where Name = 'taro';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ref Name_Status_IDX Name_Status_IDX 402 const 1 100.00 null

これはシンプルにName_Status_IDXが使われています。

StatusをWhere句に指定

explain select * from Users where Status = 'active';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ALL null null null null 1 100.00 Using where

keyはnullとなっていて、インデックスが効いていません。 これは、最初にインデックス指定されたNameに基づいてインデックスが作成されて、それに基づいてさらにStatusのインデックスが作成されているためです。そのため、Status単体のインデックスは効いていません。

NameとStatusをandでWhere句に指定

explain select * from Users where Name = 'taro' and Status = 'active';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ref Name_Status_IDX Name_Status_IDX 605 const,const 1 100.00 null

こちらはNameとStatusがandで検索条件に入っているため、複合インデックスが効いています。

NameとStatusをorでWhere句に指定

explain select * from Users where Name = 'taro' or Status = 'active';

結果

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE Users null ALL Name_Status_IDX null null null 1 100.00 Using where

Name or Statusとそれぞれの検索を行うようになっており、Nameのインデックスを元にStatusのインデックスを辿る、という動作ができないためインデックスは使用されていません。

まとめ

複合インデックスの挙動を理解することで、意図せずインデックスが使用されないケースを防ぐことが大事ですね。

anyとunknownの違いとどちらを使うべきかについて

概要

TypeScriptにはanyとunknownという型があります。
これらは型チェックを無効にするものですが、動作には大きく違いがあります。

anyとunknownの動作の違い

例えば以下の実装があったとします。 ( v5.3.3の環境で実行しています)

const un: unknown = 'hello'
const an: any = 'hello'

console.log(un.length) // 'un' is of type 'unknown'.
console.log(an.length) // 5

console.log(un.charAt(3)) // 'un' is of type 'unknown'.
console.log(an.charAt(3)) // "l" 

unknownは代入された変数が持つ型(今回はstring)のプロパティやメソッドを使用できませんが、anyは使うことができます。

逆に言えば、プロパティやメソッドを使う必要がない場合は、anyではなくunknownを使った方が意図しない使い方を縛れるというメリットがあります。
なのでanyである必要がないと判断した場合、unknownを使う方が良さそうです。