🍪

JavascriptでCookieを型安全に操作する

2024/08/10に公開2

概要

クライアント側のJavascriptでCookieを型安全に操作する処理を考えてみました。

ユーザーが入力するフォームを想定しています。

イメージ

対象読者

  • JavaScriptのフレームワークを使って開発している方
  • Cookieを使っている方

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

それでは以下が本編です。

結論

  • cookieのkey, value(object)を型で定義して、型安全にする。
  • 取得した際に、オブジェクトのプロパティが全て存在するか判定する。

説明すること

  • cookieの操作を型安全にする処理

cookieの操作を型安全にする処理

utils/cache/client/index.ts
import type { CookieSerializeOptions } from "cookie";
import { destroyCookie, parseCookies, setCookie } from "nookies";

type ResignMemberShip = {
	reasonType: string; // 退会理由
	reasonDetail: string; // 詳細
	agreement: boolean; // 同意する
};

export const CACHE_KEYS = {
	RESIGN_MEMBERSHIP: "RESIGN_MEMBERSHIP",
} as const;

const CACHE_PROPERTY_NAMES = {
	[CACHE_KEYS.RESIGN_MEMBERSHIP]: ["reasonType", "reasonDetail", "agreement"],
};

type CacheKeys = (typeof CACHE_KEYS)[keyof typeof CACHE_KEYS];

type CacheValues = {
	[CACHE_KEYS.RESIGN_MEMBERSHIP]: ResignMemberShip;
};

const DEFAULT_COOKIE_OPTIONS: CookieSerializeOptions = {
	httpOnly: false, // クライアントの実装なので常にfalse
	secure: process.env.NODE_ENV === "production",
	sameSite: "strict",
	maxAge: 60 * 60 * 24 * 7, // 1週間
	path: "/", // すべてのページでアクセス可能
};

export const setCache = <K extends CacheKeys, V extends CacheValues[K]>(
	key: K,
	object: V,
	options?: CookieSerializeOptions,
): void => {
	const cookieOptions = {
		...DEFAULT_COOKIE_OPTIONS,
		...options,
	};
	const value = JSON.stringify(object);
	setCookie(null, key, value, cookieOptions);
};

export const getCache = <K extends CacheKeys, V extends CacheValues[K] | null>(
	key: K,
): V | null => {
	const cookies = parseCookies();
	const cookieValue = cookies[key] as string | undefined;
	const parseValue = (value: string): V | null => {
		return JSON.parse(value);
	};
	try {
		if (typeof cookieValue === "string" && cookieValue) {
			const parsedObject = parseValue(cookieValue);
			// パースした結果がnullの場合はnullを返す
			if (parsedObject === null) {
				return null;
			}
			// 期待する型のプロパティがすべて存在するかチェック
			const allPropertiesExist = Object.keys(parsedObject).every((k) => {
				return CACHE_PROPERTY_NAMES[key].includes(k);
			});
			// すべてのプロパティが存在する場合のみ返す
			if (parsedObject && allPropertiesExist) {
				return parsedObject;
			}
		}
		return null;
	} catch (error) {
		console.error(error); // エラー処理は一旦省略
		return null;
	}
};

export const removeCache = (
	key: CacheKeys,
	options?: CookieSerializeOptions,
): void => {
	destroyCookie(null, key, options);
};

型についての説明

keyにはユースケース名を指定するようにしました
ユースケースに対応するキャッシュの値をオブジェクト単位で保存します。

型定義一部抜粋
export const CACHE_KEYS = {
	RESIGN_MEMBERSHIP: "RESIGN_MEMBERSHIP",
} as const;

type ResignMemberShip = {
	reasonType: string;
	reasonDetail: string;
	agreement: boolean;
};

type CacheValues = {
	[CACHE_KEYS.RESIGN_MEMBERSHIP]: ResignMemberShip;
};

さらに、keyを指定するとvalueの型を要求する ようにしました。

<K extends CacheKeys, V extends CacheValues[K]>

これによって、keyを指定した段階で値の型が決まって、型チェックが実行されるようになります。

setCacheの例

getCacheの例

なんでオブジェクトをJSON文字列で操作するの?

オブジェクトをJSON文字列として操作する理由は、パース時に保存時の型(Number, boolean, Array)で復元したいからです。(そのまま出し入れすると文字列になってしまう)

なので「ユースケースごとにオブジェクト単位で保存する」のが良さそうと思いました!

値の検証

cookieは、有効期限があり揮発性のあるキャッシュです。ユーザーがブラウザ操作して変更/削除することもできます。そのためGet処理では下記の全てを満たす場合にのみ、値(と期待する型)を返すようにしました。

  1. keyに対応するcookieの値(JSON文字列)を取得できるか?
  2. 1の値をJSON.parseできるか?
  3. 2の値が存在するか?
  4. 3の値に期待する型のプロパティが全て存在するか?
getCacheの一部抜粋
	const cookies = parseCookies();
	const cookieValue = cookies[key] as string | undefined;
	const parseValue = (value: string): V | null => {
		try {
			return JSON.parse(value) as V;
		} catch {
			return null;
		}
	};
	try {
		// 1. keyに対応するcookieの値(JSON文字列)を取得できるか?
		if (typeof cookieValue === "string" && cookieValue) {
			// 2. 1の値をJSON.parseできるか?
			const parsedObject = parseValue(cookieValue);
			// 3. 2の値が存在するか?
			if (parsedObject === null) {
				return null;
			}
			// 4. 3の値に期待する型のプロパティが全て存在するか?
			const allPropertiesExist = Object.keys(parsedObject).every((k) => {
				return CACHE_PROPERTY_NAMES[key].includes(k);
			});
			// すべてのプロパティが存在する場合のみ返す
			if (parsedObject && allPropertiesExist) {
				return parsedObject;
			}
		}
		return null;
	} catch (error) {
		console.error(error); // エラー処理は一旦省略
		return null;
	}

まとめ

「キー名をユースケース名にして、対応する値を型で制御する」でcookieを型安全に操作する例を説明しました。
このパターンは、他の処理でも応用が効くため覚えておくと便利かもしれません!

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

Discussion

r-sugir-sugi

React.jsの場合の注意点

画面遷移: a 入力画面 -> b 入力確認画面 -> c 完了画面 で

a 入力画面: set, get
b 入力確認画面: get, remove
c 完了画面: -

する場合、

bでremove後にページ遷移すると、ページ遷移直前に再実行されるためuseMemoしておく必要がある。

b 入力確認画面の例
const Template: FC = () => {
	const router = useRouter();
	// ページ遷移直前にコンポーネント内が再実行されるため、初期描画時の値をuseMemoで保持しておく(useMemoしないとcacheがnullの状態で処理が実行される)
	const cache = useMemo(() => getCache(CACHE_KEYS.RESIGN_MEMBERSHIP), []);

	// キャッシュが存在しない場合は入力画面に戻す
	if (cache == null) {
		window.confirm("入力した値が存在しません。入力画面に戻ります。");
		return router.push(loginRequiredPages.mypageResignMemberInput.path());
	}

	const {
		handleSubmit,
		register,
		formState: { isSubmitting, isValid, errors },
	} = useResignMemberForm({
		defaultValues: cache,
	});

	const resignMemberMutation = useResignMember();

	const submitHandler = async (
		data: ResignMemberSchema,
		event?: BaseSyntheticEvent,
	) => {
		event?.preventDefault?.();

		const res = await resignMemberMutation({
			reasonType: data.reasonType,
			reasonDetail: data.reasonDetail,
			agreement: data.agreement,
		});

		if (!res.data) {
			return;
		}
		// 成功時に入力内容を削除する
		removeCache(CACHE_KEYS.RESIGN_MEMBERSHIP);
		await router.push(publicPages.index.path());
	};

	...割愛
	return ()
}
r-sugir-sugi

TODO: オブジェクトの型定義
as const satisfies Hoge みたいにした方が型推論できそう(楽そう)