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を組み合わせて使うこともできる。
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;