面倒くさがり屋のためのTypeScript環境

ケツロン構成 - 2024年11月版 -

  • Vite 5.4.10
  • Vitest 2.1.4
  • Valibot 1.0.0 beta7
  • Sinon.JS 19.0.2
  • power-assert ( @power-assert/runtime 0.2.1 + rollup-plugin-power-assert 0.1.1)
  • Deno 2.0.0

Vite は 5.4.10 で試したけど、6 でも変わらないんじゃないかと思う(期待)。

これを書いている人のバックグラウンド

  • JavaScript自体はとても長く触っているけど専業ではない
  • フロントエンドもバックエンドもやるけどバックエンドを JS で書きたいとは思わない
  • TypeScript は JS のゆるふわさに対抗するための現実的でよい手段
  • TDD が好き
  • もうトシなのでオシゴト的にはいわゆる上流的な話が多め

やりたいこと

既存の知識、既存の資産は活かしたい

  • Node.js
  • npm
  • ES Modules
  • Vite
  • Vitest
  • Sinon.JS
  • power-assert

Node.js と npm のエコシステムは強大である。そんだけ。Babel も Webpack も時代を作ったが、さすがに厳しみを感じる。esbuild や swc などで Node.js の制約、コンパイル時間を緩和しつつ、TypeScript のおいしいところだけ頂戴したい。そういう感じ。

さすがにそろそろES Modules前提でよいのでは

もちろん例外はあるんだけど、例外の部分以外は ES Modules 前提で書いて動かす、でよいと思う。ESM 関係のよいデキゴトはだいたい以下のような感じ。

  • 2017年リリースのブラウザから ESM は動作している
  • 2021年リリースの Node.js 14 から ESM 対応
  • 2023年 リリースの Node.js 21 で –experimental-default-type で ESM をデフォルトにできるように

従来、自分は素の Node.js プロジェクトだとできるだけ Mocha + CommonJS ( ESM でも問題ないならそれ ) でやってきていて、ブラウザ向けには Vite を使う、という感じで選択していたんだけど、もうさすがに ESM を基本に置いたうえで CommonJS にも必要なら対応する、というスタンスでよいと思う。

Vite は

  • ES Modules / CommonJS / TypeScript 対応
  • TypeScript の ESM 対応

をいっぺんに解決できる1。だから全体的に Vite 寄せでよいのではないかと考えている。Vite は beta の頃から使っていて慣れてるし、設定も少ない。

まず動かしたい

まず動かしたい。動いた結果をテストコードで確認したい。TDD でいきたい。

TypeScript 自体はよいものだと思うけど、type 定義に妙に時間を取られてしまうのは決してよいことと思っていない。type は実際の動作を必要としないため、最も早いフィードバックを得ることができる。しかし type の定義の正しさ、あるいは誤りを動作で確認することが難しい。つまり複雑な type を新たに定義する、あるいは変更したくなった時に自信を持って取り組むことが難しいという問題を抱えている。コードが type 定義を正しく満たしているかは確認できるが、type そのものが正しいかどうかの確認は難しい。

だからまず動作するものを用意し、その情報をもとに type を練っていく、まさに TDD によって設計を導出するようなアプローチを採りたい。

そのためにはテストランナーは前提としておきたい。Node.js も 20 から stable の Test Runner が標準で存在するが、TypeScript 対応など考えると Vitest を標準にしておくのでよさそう。

ユニットテストをたくさん回したい

  • Vitest
  • Sinon.JS

Vitest は標準で watch 付きでテストを回すので、これで

$ vitest <target test file>

を起動しておくと、関係するファイルを書き換えるたびにテストを自動実行してくれる。これがかなり速いのでとても快適に TDD できる。

ただ Jest 互換の独自 test double については不採用とした。Jest の頃から使ってないので。

動くtype定義としてのschema validation

これは

  • Valibot

を使うことにした。

名前だけ知ってて実際には使ったことがなかった Zod の系統に当たる Valibot は

  • Schema validation ライブラリ
  • この Schema を type 定義に変換

を実現できるもの。

例はのちほど。

assertionは標準APIでかつ情報をリッチに

ということで

  • power-assert
  • rollup-plugin-power-assert

を採用。

expect 記法も language server で補完されるのであれば「RSpec疲れ」が話題になった頃よりは十分使えそうではあるが、どうせツールは移ろいゆくし、シンプルな Node.js 標準 API に寄せておくことにした。

Vitest は Chai ベースの assertion を利用できるのだが、これが AssertionError を起こした時に長大なトレースを見せられるのは却って扱いにくいのでやめた。あれはリッチな情報とは異なると思う。

power-assert は 実際には rollup-plugin-power-assert の peerDependencies である @power-assert/runtime もインストールする必要があることに注意。

最後にコンパイルエラーを検知したい

もうこれは最後です。どうせイマドキ language server 前提でしょ?

ここで使うのが TypeScript 本家じゃなくて Deno.

というのも、上に挙げた ESM の解決、Node.js の最新バージョンの機能への追随など、TypeScript で実現しようとするとどうしてもやっかいな tsconfig.json を避けて通るのが難しい。

Deno なら単純にファイルのリストを作って与えるだけでなんとかなりそうな手応えがある。

今回の構成でできること

サンプルのコード全体を載せておく。

import { describe, it } from 'vitest'
import assert from 'node:assert'
import * as v from 'valibot'

const Latitude = v.number()
const Longitude = v.number()
const Coordinate = v.strictObject({
  lat: Latitude,
  lon: Longitude,
})

type Coordinate = v.InferInput<typeof Coordinate>

const setCoordinate = (params: Coordinate): boolean => {
  const result = v.safeParse(Coordinate, params)

  if (result.success) {
    console.log('ok')
    return true
  } else {
    console.error(v.flatten(result.issues).nested)
    return false
  }
}

describe('Vitest example', () => {
  describe('rich diff on assertion error', () => {
    it('', () => {
      const sum = 1 + 2
      const four = 4

      assert.equal(sum, four)
    })
  })

  describe('schema validation', () => {
    it('schema valid', () => {
      assert(setCoordinate({
        lat: 136.5,
        lon: 36.5
      }))
    })

    it('schema invalid', () => {
      assert(setCoordinate({
        foo: 'bar'
      }))
    })
  })
})

power-assertの効果

例によってこんな感じの出力を得ることができる。

assert.equal(sum, four)
             |    |
             |    4
             3

Valibotの効果

基本的な使い方は以下のような感じ。ここでは v という名前空間の下に schema 定義および validation 用の関数群がある。

import * as v from 'valibot'

const Latitude = v.number()
const Longitude = v.number()
const Coordinate = v.strictObject({
  lat: Latitude,
  lon: Longitude,
})

これは意味としては以下と同等の schema になる。

type Latitude = number
type Longitude = number
type Coordinate = {
  lat: Latitude
  lon: Longitude
}

そして実際に validation を行う部分は以下。

const result = v.safeParse(Coordinate, params)

この result の中には validation 結果が含まれている。(safeParse ではなく parse を利用すると例外が上がる。)

結果にエラーがあれば Issue[] という形でその情報が結果に含まれる。これを v.flatten() で展開すると human readble になる。

v.flatten(result.issues).nested

そして、

type Coordinate = v.InferInput<typeof Coordinate>

この部分。ここで schema としての Coordinate が type としての Coordinate になる。これによって、language server が有効な環境なら、エディタ、IDE 上で自動的に type check が走り、

    it('schema invalid', () => {
      assert(setCoordinate({
        foo: 'bar'
      }))
    })

の部分は実行前から property が誤っていることを確認できる。

そして JavaScript にコンパイルするとこの行は消えてしまうが、schema validation の働きは残る。「実際に動作する type check」として残すことができるので、type を活かそうというモチベーションは従来よりもずっと高い状態を維持できる。

deno check

tsc じゃなくて deno check.

tsc は TypeScript が Node.js 上に実現されていて Babel だなんだと深い依存を持っていることに起因する面倒をいくつも持っている。なぜか設定がうまく適用できずに .d.ts に対して不要なチェックが実行されたりもする。

deno check にまったく問題がないかはなんとも言えないが、少なくとも tsc ではどうしても回避できなかった問題を deno check は苦もなく通過できたので、しばらくこれでいってみようと思う。何より速いし設定がほとんど要らない(本日n回目)。

実際に使ってみて

悪くないと思う。

これまで自分は「どうせ runtime で消えてしまう type」に合致するかしないかで時間を奪われるのが本当にイヤだった。そう、「どうせ」「奪われる」という感覚だったのだけれど、schema validator から type が起こせるのであれば事情が変わってくる。実際に動いて意図と合っていない場合の動作を自分で作ることができる。そして Vitest は Vite をベースにしているので type を無視してテストを動かすことができるので、できあがった type をきっちり適用させたとしてもテストコードは無駄にならない。これはありがたい。

必要な設定はなんと、power-assert 用のものと、package.json の "type": "module" だけ。

power-assert-monorepo/packages/rollup-plugin-power-assert at main · twada/power-assert-monorepo

さすがにこの設定は必要だが、それ以外は vitest.config.js も tsconfig.json も deno.json もなしで動く。実にありがたい。

これでとにかく面倒が勝っていた TypeScript 周りにもう少し真面目に取り組んでもいいかも。

  1. Node.js, TypeScript それぞれに ESM の対応が微妙に異なったりするのだが、それらを全部無視して Vite で解決してしまおうという魂胆。 

More