はじめに
こんにちは、クリスマスまであと数日。今年の終わりが近づいていることを日々実感しています🎄
アドベントカレンダー記事の2つ目は、ここ2、3年に出会ったTypeScriptの型を活用し、素晴らしいと感じたライブラリをいくつか紹介します。それぞれのライブラリがどのように開発者体験(DX)を向上させるのか、具体的なコード例や一部の実体験を交えて見ていきましょう。
1. Hono - 型安全な軽量Webフレームワーク
Honoは、Cloudflare社のエンジニアであるYusuke Wadaさんによって開発された高性能Webフレームワークで、人気が高まっています。ちなみに名前の由来は「炎」(ほのお)で、開発者が日本人らしいです。1
まず最初に驚くことは、なんとパスパラメーターが型安全になっています。一昔前のフレームワークでは{ [key: string]: string }やanyのようになっていて、今振り返ってみるともはや石器時代でしたね。
import { Hono } from 'hono'
const app = new Hono()
app.get('/authors/:name', (c) => {
const { name: author } = c.req.param() // { name: string }
return c.json({ author })
})
型安全なミドルウェア
従来のフレームワークでは、リクエストオブジェクトに動的にプロパティを追加すると、TypeScriptの型チェックが効きづらくなり、手動で型を付ける必要があります。HonoではContextVariableMapの仕組みにより型安全性が担保されます。
import { createMiddleware } from 'hono/factory'
declare module 'hono' {
interface ContextVariableMap {
result: string
}
}
const mw = createMiddleware(async (c, next) => {
c.set('result', 'some values') // ('result', string)
await next()
})
app.use(mw)
app.get('/', (c) => {
const result = c.get('result') // ('result') => string
return c.json({ result })
})
型安全なRPC
TypeScriptにおいて型安全なRPCといえばtRPCが有名ですが、Honoはサーバーの定義からAppTypeを導出することでサポートします。
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const schema = z.object({
id: z.number(),
title: z.string()
})
const app = new Hono().basePath('/v1')
const routes = app.post('/posts', zValidator('json', schema), (c) => {
const data = c.req.valid('json')
return c.json({
message: `${data.id.toString()} is ${data.title}`
})
})
type AppType = typeof routes
クライアントからはAppTypeにより型安全なAPIリクエストが作れます。
import { hc } from 'hono/client'
const client = hc<AppType>("http://localhost:8787")
// 型安全 `v1.posts.$post`
const res = await client.v1.posts.$post({
// 型安全 `json: { id: number; title: string; }`
json: { id: 123, title: "Hello" },
})
const data = await res.json()
console.log(data.message) // string
Elysia - 型からOpenAPIを生成する
同様のコンセプトで、Bun特化に開発されたElysia(公式の日本語読み方「エリシア」)というフレームワークもあります。直接の関係はありませんが、最近BunがAnthropicに買収されたことが話題になっていますね。2
Honoと同等かそれ以上の型安全性を持ち、しかもBunの高速性と組み合わせることでより高いパフォーマンスを実現できていて、これからの発展が注目されています。
import { Elysia } from 'elysia'
import { openapi, fromTypes } from '@elysiajs/openapi'
export const app = new Elysia()
.use(
openapi({
// これで型からOpenAPIドキュメントを自動生成
references: fromTypes()
})
)
.get('/', { test: 'hello' as const })
.post('/json', ({ body, status }) => body, {
body: t.Object({
hello: t.String()
})
})
.listen(3000)
2. ArkType - 直感的で高速なバリデーション
実行時のデータ型チェック(バリデーション)といえばZodが一番有名ですが、新興勢力のArkTypeがすごい進化を遂げています。
- TypeScript文法のように直感的な書き方
- Zodより約20倍高速なパフォーマンス
import { z } from 'zod'
const User = z.object({
name: z.string(),
age: z.number().min(0).max(120),
email: z.string().email(),
isActive: z.boolean().optional(),
})
type User = z.infer<typeof User>
上記のZodの書き方に対し、ArkTypeの方がTypeScriptの型定義の文法に近く、より直感的になります。数値の範囲指定も文字列で自然に表現できます。
import { type } from 'arktype'
const User = type({
name: 'string',
age: '0 <= number.integer <= 120',
email: 'string.email',
'isActive?': 'boolean',
})
type User = typeof User.infer
ArkRegex - 型安全な正規表現
さらに、最近ArkTypeにArkRegexという機能が追加されて、なんと型安全な正規表現の時代がやってきました!
import { regex } from 'arktype'
const Email = regex('^(?<name>\\w+)@(?<domain>\\w+\\.\\w+)$')
const parts = Email.exec('user@example.com')
if (parts?.groups) {
const {
name, // string
domain, // `${string}.${string}`
} = parts.groups
}
3. Kysely - 型安全なDB操作
先日1ヶ月ほどAndroid開発に触れ、Roomのコンパイル時のSQLやスキーマ検証に感動しました。KyselyはORMではなくSQLの軽量なラッパーですが、似たような体験をTypeScriptに持ってきました。
まずはこのように型でDBのスキーマを定義します。
import { Generated } from 'kysely'
interface Database {
user: {
id: Generated<number>
name: string
gender: 'man' | 'woman' | 'other'
age: number
}
post: {
id: Generated<number>
author_id: number // FK -> user
text: string
}
}
これでSQLのような直感的な書き方で、型安全なDB操作ができます。
import SQLite from 'better-sqlite3'
import { Kysely, SqliteDialect } from 'kysely'
const dialect = new SqliteDialect({
database: new SQLite(':memory:'),
})
const db = new Kysely<Database>({ dialect })
// {
// text: string;
// id: number;
// author: string;
// }[]
const posts = await db
.selectFrom('user')
.where('id', '=', 123)
.innerJoin('post', 'author_id', 'user.id')
.select(['post.id', 'name as author', 'text'])
.execute()
カラム名のタイポを心配することなく、スキーマの追加、クエリのリファクタリングなど、コンパイル時の型チェックで素晴らしいDXを実現しました。
4. TanStack Router - 型安全なルーティング
今年Next.jsは型付きルートを整備しましたが、中途半端な形で、searchParamsについてはnuqsなどのライブラリもしくは自前で補完する必要があります。
// src/app/posts/[page].tsx
async function PostsPage({ params, searchParams }) {
// page: string
const { page } = await params
// sortParam: string | string[] | undefined
const { sort: sortParam } = await searchParams
const sort = ['newest', 'oldest', 'popular'].includes(sortParam) ? sortParam as 'newest' | 'oldest' | 'popular' : 'newest'
}
TanStack Routerでは、URLのパスパラメーターだけでなく、searchParamsのバリデーションと型推論までできるため、手軽にルーティングの型安全性が保証されます。
// src/routes/posts/$page.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const searchSchema = z.object({
sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
})
const Route = createFileRoute('/posts/$page')({
validateSearch: searchSchema,
})
function PostsPage() {
// page: string
const { page } = Route.useParams()
// sort: 'newest' | 'oldest' | 'popular'
const { sort } = Route.useSearch()
}
ページへリンクする時に型安全の恩恵が受けられます。
import { Link } from '@tanstack/react-router'
<Link
to="/posts/$page"
params={{ page: '123' }}
search={{ sort: 'popular' }}
/>
5. WagmiとABIType - Web3の型安全性
去年までの約3年間はWeb3関連のプロジェクトの開発に取り組んでいました。Web3の仕組みは、Solidity言語で開発したスマートコントラクト(ブロックチェーン上のイーサリアムVMで動くプログラム)があって、通常のサーバーとブラウザからはethers.jsなどのライブラリを利用し、スマートコントラクトとRPC通信を行って様々な機能を実現します。その通信のプロトコルはABI(Application Binary Interface)により定義されています。
TypeScriptから見るとethers.jsはany型だらけで、型安全には程遠い、恐ろしい世界でした。
contract = new Contract("0x...", abi, provider)
// result: any
const result = await contract.balanceOf('0x123')
当時この問題を一部解決したのはTypeChainのようなABIからTypeScriptの型を生成するツールでした。
いつの間にか現代になって、Wagmiという素晴らしいライブラリが登場しました。その内部では魔法のようなライブラリABITypeを使い、ABIのJSON定義からTypeScriptの型を推論することにより、RPC通信が型安全になりました。
import { Address, useReadContract } from 'wagmi'
// Solidityプログラムをコンパイルすることで取得
const abi = [
{
name: 'balanceOf',
type: 'function',
inputs: [{ name: 'owner', type: 'address' }],
outputs: [{ name: 'balance', type: 'uint256' }],
stateMutability: 'view',
}
] as const
function TokenBalance(owner: Address) {
// data: bigint | undefined
const { data, isError, isLoading } = useReadContract({
address: '0x...',
abi,
functionName: 'balanceOf',
args: [owner],
})
}
さらに、ABIは上記のJSON形式だけでなく、Solidity言語の表記まで対応しています。つまり、型レベルで文法の解析を行うことになっています⁉️
const abi = [
'function balanceOf(address owner) view returns (uint256)',
] as const
// data: bigint | undefined
// const { data, isError, isLoading } = useReadContract({ abi, ... })
6. TypeGPU - WebGPUの型安全性
今年の頭にWebGPUをいじる機会がありまして、不慣れということもあり、Shaderプログラム(WGSL)とホストプログラム(TypeScript)との間で整合性が取れない問題に悩まされました。次のような簡単なプログラムであればミスしてもすぐ修正できそうですが、当時は複数のCompute Shaderを含むものだったので、正直地獄でした。
// ただの文字列でTypeScriptとの関連が分かりづらい
const shaderCode = `
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
};
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
// ...
}
@fragment
fn fragmentMain(@location(0) uv: vec2f) -> @location(0) vec4f {
let purple = vec4f(0.769, 0.392, 0.230, 1.0);
let blue = vec4f(0.114, 0.847, 0.241, 1.0);
let ratio = (uv.x + uv.y) / 2.0;
return mix(purple, blue, ratio);
}
`
const shaderModule = device.createShaderModule({
code: shaderCode,
})
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: shaderModule,
entryPoint: 'vertexMain',
},
fragment: {
module: shaderModule,
entryPoint: 'fragmentMain',
targets: [
{
format: presentationFormat,
},
],
},
primitive: {
topology: 'triangle-list',
},
})
// ...
TypeGPUはTypeScriptの型システムを活用し、こういった問題を解決します。残念ですが、さすがに型レベルで直接WGSLを解析することはできず、TypeScriptでShaderを書くことになり、バンドラーのプラグイン3でWGSLに変換されます。
WGSLで書くのもサポートされていますが、型チェックの恩恵が薄れたので個人的にはあまり好ましくないです。
import tgpu from 'typegpu'
import * as d from 'typegpu/data'
import * as std from 'typegpu/std'
const purple = d.vec4f(0.769, 0.392, 1.0, 1)
const blue = d.vec4f(0.114, 0.447, 0.941, 1)
const getGradientColor = (ratio: number) => {
'use gpu'
return std.mix(purple, blue, ratio)
}
const mainVertex = tgpu['~unstable'].vertexFn({
in: { vertexIndex: d.builtin.vertexIndex },
out: { outPos: d.builtin.position, uv: d.vec2f },
})(({ vertexIndex }) => {
const pos = [d.vec2f(0.0, 0.5), d.vec2f(-0.5, -0.5), d.vec2f(0.5, -0.5)]
const uv = [d.vec2f(0.5, 1.0), d.vec2f(0.0, 0.0), d.vec2f(1.0, 0.0)]
return {
outPos: d.vec4f(pos[vertexIndex], 0.0, 1.0),
uv: uv[vertexIndex],
}
})
const mainFragment = tgpu['~unstable'].fragmentFn({
in: { uv: d.vec2f },
out: d.vec4f,
})(({ uv }) => {
return getGradientColor((uv[0] + uv[1]) / 2)
})
const root = await tgpu.init()
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
const pipeline = root['~unstable']
.withVertex(mainVertex, {})
.withFragment(mainFragment, { format: presentationFormat })
.createPipeline()
// ...
普通の開発ではThree.jsやUnityなどを利用し、直接WebGPUを触る機会は中々ないですが、TypeGPUに出会えた喜びはまるでLinuxカーネル開発にRustが使えるようなレベルでした🥳4
まとめ
いかがでしょうか?ミドルウェア、RPC・OpenAPI、バリデーション、DB操作、ルーティングなどWeb開発に必要不可欠なものから、Web3やWebGPUなどの先端技術まで、TypeScriptの型システムがキラキラ輝いている6つのライブラリを紹介しました。
それぞれ異なる領域をカバーしていますが、共通しているのはより多くのエラーをコンパイル時に検出し、実行時のエラーを減らすという思想です。もしこの思想に共感し、このようなライブラリに興味があれば、ぜひ試してみてください!
目指せ❣️依存型❣️ な、ナンデモナイデスー😅
それでは、良いクリスマスをお過ごしください✨