SvelteKit + sveltekit-superformsで入力チェック

# はじめに

Webアプリケーションを作成するにあたって、一番面倒くさい作業が入力チェックではないでしょうか。[SvelteKit] で簡単に入力チェックを実現できる方法を探していたところ [sveltekit-superforms] というライブラリを見つけました。

[sveltekit-superforms] を使った入力チェックがどんな感じになるのか試してみました。

# sveltekit-superforms って何？

簡単に言うと、[Zod] というバリデーションライブラリを [SvelteKit] で利用しやすくしたライブラリです。

以下、ホームページからの引用です。

:::note
Superforms is a SvelteKit library that helps you with server-side validation and client-side display of forms.
It enables you to use a Zod validation schema as a single source of truth, with consistent handling of form data and validation errors.

訳:
Superformsは、フォームのサーバーサイドバリデーションとクライアントサイド表示を支援するSvelteKitのライブラリです。
Zodバリデーションスキーマを単一の真実のソースとして使用することができ、フォームデータとバリデーションエラーの一貫した処理が可能です。
:::

# 導入

まずは [SvelteKit] のスケルトンアプリをセットアップします。
```:ターミナル
npm create svelte@latest sveltekit-validation

(こんな感じで選択)
┌  Welcome to SvelteKit!
│
◇  Which Svelte app template?
│  Skeleton project
│
◇  Add type checking with TypeScript?
│  Yes, using JavaScript with JSDoc comments
│
◇  Select additional options (use arrow keys/space bar)
│  Add ESLint for code linting, Add Prettier for code formatting, Add Playwright for browser testing, Add Vitest for unit testing
│
└  Your project is ready!
```

[sveltekit-superforms] をインストールします。

```:ターミナル
npm i -D sveltekit-superforms zod
```

# 入力チェックの実現

[sveltekit-superforms] の [CRUDチュートリアル](https://superforms.vercel.app/crud) を参考にして、ユーザー情報の入力チェック、という体で実装してみます。

作成・修正したファイルは4つです。

まずは、バリデーションスキーマと、ユーザーのCRUDを担当する `users.js` を作成します。
```js:src/lib/users.js
import { z } from 'zod';

// See https://zod.dev/?id=primitives for schema syntax
/**
 * ユーザーのスキーマ
 */
export const userSchema = z.object({
  id: z.string().max(20),
  name: z.string().min(2).max(20).endsWith("!"),
  email: z.string().email()
});

/** @typedef {z.infer<typeof userSchema>} UserDB */

/**
 * ユーザーIDを生成する
 * 
 * @returns {string} id
 */
export const userId = () => String(Math.random()).slice(2);

/**
 * 全ユーザーを取得する
 * 
 * @returns {UserDB[]}
 */
export const getAll = () => users;

/** 
 * id に一致するユーザーを取得する
 * 
 * @param {string} id
 * @return {UserDB|undefined}
 */
export const get = (id) => {
  return users.find((u) => u.id == id);
}

/** 
 * ユーザーを登録する
 * 
 * @param {UserDB} user
 */
export const create = (user) => {
  users.push(user);
}

/** 
 * ユーザーを更新する
 * 
 * @param {UserDB} user
 */
export const update = (user) => {
  const currentUser = get(user.id);
  if (!currentUser) {
    return;
  }
  const index = indexOf(currentUser);
  users[index] = user;
}

/**
 * id に一致するユーザーを削除する
 * 
 * @param {string} id
 */
export const remove = (id) => {
  const currentUser = get(id);
  if (!currentUser) {
    return;
  }
  const index = indexOf(currentUser);
  users.splice(index, 1);
}

/**
 * @param {UserDB} user 
 * @returns {number}
 */
const indexOf = (user) => users.indexOf(user);

/**
 * 仮のユーザーデータベース
 * 
 * @type UserDB[]
 */
const users = ([
  {
    id: userId(),
    name: 'Important Customer',
    email: 'important@example.com'
  },
  {
    id: userId(),
    name: 'Super Customer',
    email: 'super@example.com'
  }
]);
```

サーバーサイドで入力チェックする処理を `+page.service.js` に実装します。

```js:src/routes/+page.service.js
import { superValidate, message } from 'sveltekit-superforms/server';
import { error, fail, redirect } from '@sveltejs/kit';

import { getAll, get, create, update, remove, userId, userSchema } from '$lib/users';

/** @typedef {import('$lib/users').UserDB} UserDB */

/**
 * ユーザーフォームのスキーマ
 */
const schema = userSchema.extend({
	id: userSchema.shape.id.optional()
});

/** @type {import('./$types').PageServerLoad} */
export const load = (async ({ url }) => {
	const id = url.searchParams.get('id');
	const user = id ? get(id) : null;

	if (id && !user) throw error(404, 'User not found.');

	// バリデーション実施済みのフォームを取得
	const form = await superValidate(user, schema);

	return { form, users: getAll() };
});

/** @type {import('./$types').Actions} */
export const actions = {
	default: async ({ request }) => {
		const data = await request.formData();

		// バリデーション実施済みのフォームを取得
		const form = await superValidate(data, schema);

		if (!form.valid) return fail(400, { form });

		// ユーザーの登録・更新・削除
		const targetUser = form.data;
		if (!targetUser.id) {
			const id = userId();
			create({ ...targetUser, id: id });
			throw redirect(303, `?id=${id}`);
		} else {
			const user = get(targetUser.id);
			if (!user) throw error(404, 'User not found.');

			if (data.has('delete')) {
				remove(user.id);
				throw redirect(303, '?');
			} else {
				// @ts-ignore
				update(targetUser);
				return message(form, 'User updated!');
			}
		}
	}
};
```

画面表示機能を `+page.svelte` に実装します。

```html:src/routes/+page.svelte
<script>
	import { superForm } from 'sveltekit-superforms/client';

	/** @type {import('./$types').PageData}*/
	export let data;

	const { form, errors, constraints, enhance, delayed, empty } = superForm(data.form);
</script>

<h3>User list</h3>

<!-- ユーザー一覧 -->
<div class="users">
	{#each data.users as user}
		<a class:current={$form.id === user.id} href="?id={user.id}">{user.name}</a><span>&#47;</span>
	{/each}
</div>

<!-- 作成ボタン -->
<form action="/">
	<button disabled={!$form.id}>Create new</button>
</form>

<h2>{!$form.id ? 'Create' : 'Update'} user</h2>

<!-- ユーザーフォーム -->
<form method="POST" use:enhance>
	<!-- id -->
	<input type="hidden" name="id" bind:value={$form.id} />

	<!-- Name -->
	<label>
		Name<br />
		<input name="name" data-invalid={$errors.name} bind:value={$form.name} {...$constraints.name} />
		{#if $errors.name}<span class="invalid">{$errors.name}</span>{/if}
	</label>

	<!-- E-mail -->
	<label>
		E-mail<br />
		<input
			type="email"
			name="email"
			data-invalid={$errors.email}
			bind:value={$form.email}
			{...$constraints.email}
		/>
		{#if $errors.email}<span class="invalid">{$errors.email}</span>{/if}
	</label>

	<button>Submit</button>

	{#if !$empty && !!$form.id}
		<button
			name="delete"
			value="delete"
			on:click={(e) => !confirm('Are you sure?') && e.preventDefault()}
			class="danger">Delete user</button
		>
	{/if}
	{#if $delayed}Working...{/if}
</form>

<style>
	.invalid {
		color: red;
	}

	.danger {
		background-color: brown;
	}

	.users > * {
		display: inline-block;
		white-space: nowrap;
		overflow-x: hidden;
		padding: 0 0.5rem;
	}

	.users > .current {
		background-color: rgb(237 191 191);
		border-radius: 1rem;
	}
</style>
```

最後に、必須ではないのですが見栄えを良くするために、CSS を動導入しておきます。

```diff_html:src/app.html
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
+		<link rel="stylesheet" href="https://unpkg.com/normalize.css@8.0.1/normalize.css" />
+		<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" />
		<meta name="viewport" content="width=device-width" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>
```

# 動作確認

起動します。

```:ターミナル
npm run dev
```

こんな画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/d58dc64c-3e54-0770-3b5c-15c8ac82976f.png)

今回は `Name` は末尾が「!」でなけれなならない仕様にしました。また、`E-mail` はメールアドレス形式でなければならない仕様です。
というわけで、エラーを発生させてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/506cea28-9e05-56b9-4240-f07b2d2713b2.png)
エラーメッセージが表示されましたね！:blush:

# エラーメッセージを日本語化

2023/4/19 追記：以下の記事で日本語化できましたー。

https://qiita.com/fsd-tetsu/items/5ee9a56c742ca2106025

<details><summary>日本語化できなかった頃の話</summary>
エラーメッセージが英語のままなので、日本語化したいですよね。[zod-i18n-map] というライブラリでいい感じにできそうなのでやってみましたが、エラーメッセージが表示されなくなっちゃいました。:disappointed_relieved:

バージョンの違い等が原因かもしれませんね。

例えば以下の様に検証用の `test.js` ファイルを作成して、
```js:test.js
import i18next from 'i18next'
import { z } from 'zod'
import { zodI18nMap } from "zod-i18n-map"
import translation from 'zod-i18n-map/locales/ja/zod.json' assert { type: "json" };

// i18nextの初期設定
i18next.init({
	lng: 'ja',
	resources: {
		ja: { translation }, // キーをzodからtranslationに変更
	},
});

z.setErrorMap(zodI18nMap);

// スキーマを定義
const schema = z.object({
	name: z.string().min(2).max(20).endsWith("!"),
});

// テスト用のデータを定義
const form = {
	name: "贅肉すぐる"
};

try {
	schema.parse(form);
} catch (e) {
	console.log("error message:", e.errors[0].message);
}
```

実行してみると、エラーメッセージが `undefined` になってしまいました。
```:ターミナル
node test.js
...
error message: undefined
```

ちなみに、`z.setErrorMap(zodI18nMap);` をコメントアウトすると、ちゃんとエラーメッセージが表示されます。
```:ターミナル
node test.js
...
error message: Invalid input: must end with "!"
```

いろいろ調査してみましたが、私の能力では簡単に直せそうではありませんでした。ChatGPT も聞いてみましたが解決せずでした。

ただし、[zod-i18n-map] は使わずに [i18next] という多言語化ライブラリを直接使って、独自にメッセージ変換の仕組みを構築すれば何とかなりそうでした。

試しに、不完全ながら以下のように修正してみました。
```diff_javascript:test.js
import i18next from 'i18next'
import { z } from 'zod'
-import { zodI18nMap } from "zod-i18n-map"
import translation from 'zod-i18n-map/locales/ja/zod.json' assert { type: "json" };

// i18nextの初期設定
i18next.init({
	lng: 'ja',
	resources: {
		ja: { translation }, // キーをzodからtranslationに変更
	},
});

+const getMessage = (issue, _ctx) => {
+	return i18next.t(`errors.${issue.code}.endsWith`, { endsWith: issue?.validation?.endsWith });
+}
+
+const myZodErrorMap = (issue, _ctx) => ({
+	message: getMessage(issue, _ctx)
+});

-z.setErrorMap(zodI18nMap);
+z.setErrorMap(myZodErrorMap);

// スキーマを定義
const schema = z.object({
	name: z.string().min(2).max(20).endsWith("!"),
});

// テスト用のデータを定義
const form = {
	name: "贅肉すぐる"
};

try {
	schema.parse(form);
} catch (e) {
	console.log("error message:", e.errors[0].message);
}
```

すると以下のように、日本語が表示されました。

```:ターミナル
node test.js
...
error message: "!"で終わる文字列である必要があります。
```

今回は完全な状態で日本語化はできませんでしたが、引き続き調査してみます。
</details>

# まとめ

[sveltekit-superforms] を使うことで、[SvelteKit] で簡単にバリデーションを実現できることが分かりました。ただし、いくつか課題があります。

- ~~エラーメッセージの日本語化~~ (2023/4/19 解決済み)
- 複数項目を跨いだバリデーション

今後はこれらについても調査してみようと思います。::smiley::

# 補足: `.js` vs `.ts` の検証

今回は、以下の検証も兼ねていました。

https://qiita.com/fsd-tetsu/items/d8b50481b6c735ffa6f3

今回作成したコードでは複雑な型は必要なかったので `.d.ts` ファイルは使いませんでした。
それを踏まえて、JSDocs で型定義してみた感想は以下になります。

- 型定義の書きやすさは .ts の方がよさそう。
- コードの見やすさは .js の方が良かった。
  - VSCode で `> Fold All Block Comments`(CTRL+K CTRL+/) を実行すればコメントが折りたたまれるので見やすくなります。コメントを再表示させるには `> Unfold All`(CTRL+K CTRL+J)を実行します。
- import() の書き方は JSDoc 形式の方が良かった。いちいちファイルの上部に移動して import しなくて良いので。

※5段階評価
|比較対象|.js|.ts|
|-|-|-|
|型定義の書きやすさ|3|4|
|コードの見やすさ|3|2|

こちらの検証はちょっとずつ継続しようと思います。

[SvelteKit]:https://kit.svelte.dev/
[sveltekit-superforms]:https://superforms.vercel.app/
[Zod]:https://zod.dev/
[zod-i18n-map]:https://github.com/aiji42/zod-i18n
[i18next]:https://www.i18next.com/