Users wanted to register with just a phone number, but the backend required an email. A small bug with real impact.
Background
Our LINE-integrated e-commerce site targets users who often do not have or do not want to enter an email address. The registration form said just enter your phone number, but submitting it returned 400 Bad Request.
The cause was simple: the frontend was sending only phone, but the backend validation had email: required.
What We Changed
Frontend (Vue + Vite)
Changed the email field to optional:
<input
v-model="form.email"
type="email"
placeholder="Email (optional)"
/>
Removed the email-required check from client-side validation.
Backend (Node.js / ts-node)
Fixed the /api/auth/register endpoint validation:
// Before
const schema = z.object({
phone: z.string(),
email: z.string().email(), // The problem
password: z.string().min(6),
});
// After
const schema = z.object({
phone: z.string().optional(),
email: z.string().email().optional(),
password: z.string().min(6),
}).refine(data => data.phone || data.email, {
message: 'Either phone or email is required',
});
Login was updated similarly:
const user = await User.findOne({
where: phone ? { phone } : { email },
});
The Gotcha
During testing, we hit a phone number already registered error. A test admin account had a real user phone number attached. A reminder of the risks of developing against production data.
Fix: guide those users to the login flow instead, with a clear already registered message.
Takeaways
Required depends on user context. Developers naturally think of email as the primary identifier, but for many real-world users, phone numbers are far more familiar. In BtoC services, auth UX directly impacts drop-off rates.
zod .refine() is great for cross-field validation. The either A or B must be present pattern cannot be expressed with individual field validators. .refine() lets you add object-level checks cleanly.
Top comments (0)