Open5

Zodの使いどころ

そんそん

APIリクエスト

jsonplaceholderからのGET APIを動的にバリデーションする一例。

import { useState, useEffect } from 'preact/hooks';
import { z } from 'zod';

const usersResponseItemSchema = z.object({
  id: z.number().int(),
  name: z.string(),
  username: z.string(),
  email: z.string().email(),
  address: z.object({
    street: z.string(),
    suite: z.string(),
    city: z.string(),
    zipcode: z.string(),
    geo: z.object({
      lat: z.string(),
      lng: z.string(),
    }),
  }),
  phone: z.string(),
  website: z.string(),
  company: z.object({
    name: z.string(),
    catchPhrase: z.string(),
    bs: z.string(),
  }),
});
const usersResponseSchema = z.array(usersResponseItemSchema);

// スキーマを拡張して上書きすることも可能
const userSchema = usersResponseItemSchema.merge(
  z.object({
    website: z.string().url(),
  })
);

// スキーマから型に変換される
type User = z.infer<typeof userSchema>;

export const App = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    void (async () => {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users'
      );
      const data = await response.json();
      const result = usersResponseSchema.safeParse(data);
      if (!result.success) {
        throw new Error(result.error.toString());
      }

      // ここの時点でresult.dataの型アノテーションがあたる
      const usersData = result.data.map<User>((user) => ({
        ...user,
        website: `https://${user.website}/`,
      }));

      setUsers(usersData);
    })();
  }, []);

  return (
    <div>
      {users.map((user) => (
        <section key={user.id}>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
          <p>{new URL(user.website).hostname}</p>
        </section>
      ))}
    </div>
  );
};
そんそん

フォーム

react-hook-formとZodを組み合わせて使うこともできる。
https://react-hook-form.com/resources/3rd-party-bindings
https://ui.shadcn.com/docs/components/form

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

const formSchema = z
  .object({
    firstName: z
      .string()
      .min(2, { message: 'First name must be at least 2 characters long' }),
    lastName: z
      .string()
      .min(2, { message: 'Last name must be at least 2 characters long' }),
    email: z.string().email({ message: 'Please enter a valid email address' }),
    phone: z.string().min(10, { message: 'Please enter a valid phone number' }),
    password: z
      .string()
      .min(8, { message: 'Password must be at least 8 characters long' }),
    confirmPassword: z.string(),
  })
  // refineメソッドで、カスタムバリデーションロジックを書ける
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      firstName: '',
      lastName: '',
      email: '',
      phone: '',
      password: '',
      confirmPassword: '',
    },
  });
  const onSubmit = (data: z.infer<typeof formSchema>) => {
    console.log(data);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      autoComplete='one-time-code'
      className='sign-up-form'
    >
      <label htmlFor='first-name'>First Name: </label>
      <input type='text' id='first-name' {...register('firstName')} />
      {errors.firstName && <p>{errors.firstName.message}</p>}

      <label htmlFor='last-name'>Last Name: </label>
      <input type='text' id='last-name' {...register('lastName')} />
      {errors.lastName && <p>{errors.lastName.message}</p>}

      <label htmlFor='email'>Email Address: </label>
      <input type='text' id='email' {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}

      <label htmlFor='phone'>Phone: </label>
      <input type='text' id='phone' {...register('phone')} />
      {errors.phone && <p>{errors.phone.message}</p>}

      <label htmlFor='password'>Password: </label>
      <input type='password' id='password' {...register('password')} />
      {errors.password && <p>{errors.password.message}</p>}

      <label htmlFor='password-confirmation'>Confirm password: </label>
      <input
        type='password'
        id='password-confirmation'
        {...register('confirmPassword')}
      />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type='submit'>Submit</button>
    </form>
  );
};

export default App;
そんそん

URL

import { useState, useEffect } from 'preact/hooks';
import { z } from 'zod';

const searchParamsSchema = z.object({
  // coerceを使うと、例えば、stringの'1'がnumberの1に変換される
  user_id: z.coerce.number().optional(),
});

const postsResponseItemSchema = z.object({
  userId: z.number(),
  id: z.number(),
  title: z.string(),
  body: z.string(),
});
const postsResponseSchema = z.array(postsResponseItemSchema);

type Post = z.infer<typeof postsResponseItemSchema>;

export const App = () => {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    const fetch3rdPartyPosts = async (urlSearchParams?: string) => {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts' + (urlSearchParams ?? '')
      );
      const data = await response.json();
      const result = postsResponseSchema.safeParse(data);
      if (!result.success) {
        console.error(result.error);
        return;
      }

      const postsData = result.data;
      setPosts(postsData);
    };

    void (async () => {
      const params = new URLSearchParams(window.location.search);
      const paramsObject = Object.fromEntries(params);
      const paramsResult = searchParamsSchema.safeParse(paramsObject);
      if (!paramsResult.success) {
        // コンソールエラー出しつつ、全件取得などのフロント側の処理を続行できたりする
        console.error(paramsResult.error);
        await fetch3rdPartyPosts();
        return;
      }

      const userId: number | undefined = paramsResult.data.user_id;
      await fetch3rdPartyPosts(
        typeof userId !== 'undefined' ? `?userId=${userId}` : ''
      );
    })();
  }, []);

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </div>
      ))}
    </div>
  );
};
そんそん

localStorage

import { useState, useEffect } from 'react';
import { z } from 'zod';

import './App.css';

const themeSchema = z.enum(['light', 'dark']);

const initializeCurrentTheme = () => {
  const localStorageTheme = localStorage.getItem('theme') ?? undefined;

  const validatedTheme = themeSchema.safeParse(localStorageTheme);
  if (!validatedTheme.success) {
    console.error('Invalid theme in local storage');
    localStorage.removeItem('theme');
    return;
  }

  return validatedTheme.data;
};

const App = () => {
  const [currentTheme, setCurrentTheme] = useState<
    z.infer<typeof themeSchema> | undefined
  >(undefined);

  useEffect(() => {
    const validatedTheme = initializeCurrentTheme();

    setCurrentTheme(validatedTheme);
  }, []);

  return <div>Current theme: {currentTheme ?? 'Not set'}</div>;
};

export default App;
そんそん

環境変数

env.tsみたいなので定義しておく。

import { z } from 'zod';

const envSchema = z.object({
  BASE_URL: z.string(),
});

export const parsedEnv = envSchema.parse(import.meta.env);

使う側ではparse済みな環境変数を用いる。

import { parsedEnv } from './env';

const App = () => {
  return <div>{parsedEnv.BASE_URL}</div>;
};

export default App;