🦀

TS ユーザーがRustを書いてみて型への認識をすこし改めた記録

2024/12/24に公開

この記事は「レバテック開発部 Advent Calendar 2024」の24日目の記事です!
昨日の記事は、 RYO さんでした。

はじめに

最近、趣味でRustを書いています。Rustのパラダイムに触れる中で、プログラミングに対する考え方に変化が生まれました。
これまでは「型を満たすロジックを書く」ことを意識していましたが、Rustの型システムとトレイトの概念を学ぶ中ことで、「型やトレイトが要求する制約を考え、それを満たす実装を組み立てる」 という新しい視点を得ることができました。

この記事では、私の型に対する考え方の変化と、Rustがそのきっかけとなった経緯を紹介します。

プログラミングと型への考え方の変化

私がプログラミングを始めたとき、最初に触った言語はPythonでした。Pythonは直感的で書きやすい一方、アプリケーションが大規模になると、型情報の曖昧さが原因でデータの整合性を保つのが難しくなり、この経験から「型」の重要性を意識するようになりました。

その後、TypeScriptに出会い、型システムの便利さを実感しました。型を導入することで、実行時のエラーを防ぎつつ、ドメイン知識を型に反映できるため、コードの可読性や保守性が向上しました。当時は型を「ロジック設計の指針」として捉え、型を満たす形でアプリケーションを構築していました。

Rustに触れることで、型に対する考え方がさらに変化しました。Rustの型システムとトレイトの概念により、単に「型を満たす」だけでなく、「型やトレイトが要求する制約を考え、それに基づいた設計を行う」という新しい視点を得ました。

このように、私のプログラミングと型に対する考え方は、Pythonから始まり、TypeScriptの型安全を経て、Rustで「制約を考える」という新しい視点へと変化しました。次のセクションでは、TypeScriptを例に、型安全の具体例を掘り下げていきます。

型を満たすように実装する

TypeScriptでは、Zodのようなスキーマライブラリを使うことで、リクエストデータを「型安全」に扱うことができます。以下はHonoとZodを使用してリクエストを送信する際の例です。

import { z } from 'zod';

const UserRequestSchema = z.object({
  id: z.string(),
  name: z.string().min(1, "名前を空にすることはできません"),
  age: z.number().int().nonnegative("年齢は正の整数でなければなりません"),
});

// スキーマから型を生成
type UserRequest = z.infer<typeof UserRequestSchema>;

上記で定義したZodスキーマをリクエストで使用する例です。

main.ts
import { Hono } from 'hono';

const app = new Hono();
app.put('/users', async (c) => {
  try {
    const body = await c.req.json();
    const userRequest: UserRequest = UserRequestSchema.parse(body);
    // userRequest.nameはstringとして安全に扱うことができる
    // userRequest.ageはintとして安全に扱うことができる
    return c.json(userRequest);
  } catch (err) {
    if (err instanceof z.ZodError) {
      return c.json({ errors: err.errors }, 400);
    }
    return c.text('Internal Server Error', 500);
  }
});

// サーバーを開始
export default {  
  port: 3000, 
  fetch: app.fetch, 
} 

この例では、UserRequestスキーマを使用して、リクエストデータが期待通りの形式であるかを検証しています。UserRequestは必須フィールドとしてname(string型)とage(int型)を定義しており、Zodを用いることでこれらの型が正しいかを簡単に確認できます。

さらに、スキーマをparseすることで、userRequest.nameがstring型、userRequest.ageがint型であることが保証されます。そのため、これ以降のコードでは、これらを安全に利用できます。
私はこの仕組みを「型安全」と捉え、ライブラリが提供・要求しているのは「型」そのものだと考えていました。

さらなる型安全を獲得するために、Branded Typeを導入してみます。
Branded Typeとは判別可能なプリミティブ型を作るためのテクニックで、型の取り違えを防ぐために使うことができます。これにより、UserIdとPostIdといった似た型をコンパイル時に区別できるようになります。

以下は、Branded Typeの定義と活用例です。

interface User {}

// Branded Typeの定義
const userIdBrand = Symbol();
const postIdBrand = Symbol();

// UserID用の Branded Type
type UserId = string & { [userIdBrand]: unknown };
// PostId用の Branded Type
type PostId = string & { [postIdBrand]: unknown };

// UserIdを元にUserを取得する関数
async function getUser(id: UserId): Promise<User> {
  // 略
}

const userId = "user_id" as UserId;
const postId = "post_id" as PostId;

// コンパイル可能
getUser(userId)

// コンパイル不可能
getUser(postId)

これを先ほどの例に導入してみます

main.ts
import { z } from 'zod';

// Branded Typeの定義
const userIdBrand = Symbol();
// UserID 用の Branded Type
type UserId = string & { [userIdBrand]: unknown };

function createUserId(id: string): UserId {
  return id as UserId;
}

// ZodスキーマにBranded Typeを統合
const UserRequestSchema = z.object({
  id: z.string().refine((id) => { return createUserId(id) };,
  name: z.string().min(1, "名前を空にすることはできません"),
  age: z.number().int().nonnegative("年齢は正の整数でなければなりません"),
});
main.ts
// 略
try {
  const body = await c.req.json();
  const userRequest: UserRequest = UserRequestSchema.parse(body);
  // userRequest.id は UserId 型として保証されている
} catch (err) {
  if (err instanceof z.ZodError) {
    console.error(err.errors);
  }
}

この例では、ライブラリが型を提供しており、その型を利用して変換のためのロジックを提供しています。具体的には、createUserId関数を用いてstring型からUserId型への変換を実装し、型安全性を確保しています。

このとき、私が意識していたのは、Zodのrefine関数が提供するデータがstring型であること、そしてstring型からUserId型への変換を行うという、あくまで 「型」に対する意識でした。型そのものに焦点を当て、データを適切に変換するための設計を考えていました。

制約を満たすように実装する

次に、Rustでトレイトや型定義を用いて制約を明示し、それを満たす形でデータを処理する例を紹介します。以下は、tokioとserdeを用いた実装です。

use std::marker::PhantomData;

// Phantom Type を用いた型定義
#[derive(Debug)]
struct Id<T> {
    value: String,
    _marker: PhantomData<T>, // 幽霊型でIDの意味を型で表現
}

impl<T> Id<T> {
    fn new(value: String) -> Self {
        Self {
            value,
            _marker: PhantomData,
        }
    }
}

Rustでは、Branded Typeに相当するものを、PhantomDataという幽霊型(Phantom Type)を使って表現することができます。この例では、Id<T>という構造体が、T型のマーカーを持つことで、Idがどのような種類のIDを表しているのかを型で区別しています。

この構造体をaxumのリクエストで利用する例を考えます。

use axum::{extract::Json, routing::put, Router};
use serde::Deserialize;
use std::net::SocketAddr;
use tokio::net::TcpListener;

// UserID用の型
#[derive(Debug)]
struct User;

// Request用の構造体
#[derive(Debug)]
struct UserRequest {
    id: Id<User>,
    name: String,
    age: u32,
}

// Handler関数
async fn put_user(Json(payload): Json<UserRequest>) -> String {
    // payload.id は Id<User> として型が保証される
    todo!()
}

#[tokio::main]
async fn main() {
    // ルータを定義
    let app = Router::new().route("/users", put(put_user));

    // サーバーを起動
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let lister = TcpListener::bind(addr)
        .await
        .expect("Failed to bind address");
    axum::serve::serve(lister, app.into_make_service())
        .await
        .unwrap();
}

このコードはコンパイルを通すことができません。なぜなら、UserRequestが(詳細は省略しますが)Handlerのトレイト境界(制約)を満たしていないためです。ざっくりいうと、Jsonとして受け取ったリクエストをパースするために、UserRequestとその中で利用するId<T>がDeserializeトレイトを実装している必要があります。

トレイトが要求する制約を満たすために、UserRequestとId<T>にDeserializeの実装を追加します。

#[derive(Debug, Deserialize)] // 追加
struct UserRequest {
// 略
}

impl<'de, T> Deserialize<'de> for Id<T> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Ok(Id::new(value))
    }
}

Id<T>の型に対してaxumのHandlerが要求するトレイトの制約を満たすように実装を行います。

このとき、私が意識していたのは、ライブラリがどの型を要求しているかではなく、何の「制約」を要求しているかという点でした。Handlerに渡す型そのものよりも、その型が満たすべき条件や役割に注目していました。

TypeScriptとRustの違い

両方の言語で型から型への変換のためにメソッドを定義する点では共通しています。

型やインターフェースが制約を表していることは、知っている人からすれば当然のことかもしれません。しかし、私自身はこれまでの実装ではそのような考え方を深く意識することはありませんでした。
TypeScriptを書いているときは、型そのものやその型に対する操作が中心的な関心事となっており、型を満たす実装を行うことに集中していました。
一方、RustではserdeやORMなどのライブラリが制約を要求する場面が多く、型だけでなく制約にも目を向けることが多くなります。また、Rustはトレイトを含む言語設計とライブラリの設計思想が一貫しているため、その実装体験を通じて、このメンタルモデルを自然と身につけることができました。

さいごに

Rustの型システムとトレイトによる抽象化を通じて、私のコーディングのメンタルモデルが変化するのを実感しました。今までは 「型に沿った実装を考える」 ことを中心にしていましたが、Rustを書くことによって 「要求される制約を満たす実装」 を考える視点を得ることができました。

この経験は、再びTypeScriptを書く際にも役立つと感じています。また、定期的に新しいパラダイムを学ぶことで、自身の知見を広げることができるので、定期的に新しい言語のキャッチアップを行う重要性を改めて実感しました。

同僚のseoinkHaskellに触れてメンタルモデルが変わったという記事を投稿していて、大変面白い記事でした。私も近いうちにHaskellを触ってみたいと思います。


明日はyama さんが投稿します〜
レバテック開発部 Advent Calendar 2024」をぜひご購読ください!

レバテック開発部

Discussion