“Give us at least one way to contact you” is a common rule that forms usually implement badly: both the phone and email fields are marked with a required asterisk (so users fill in both), or neither is, and the error appears on whichever field the developer picked, reading “Phone is required” to someone who deliberately chose email.
“At least one of” is a group rule: no single member is required, but the group is. This page, within cross-field dependency logic, implements it for text fields and checkbox groups, with markup that tells users about the requirement before they make a mistake and an error that names the group rather than one field.
Context and prerequisites
Where “at least one” appears:
- Alternative contact methods — phone or email or postal address.
- Checkbox groups — “choose at least one topic”, “select the days you are available”.
- Alternative identifiers — passport number or national ID number.
- Optional pairs with a floor — “at least one reference” when references are free text fields.
The design points:
- No member is individually required. Marking both as
requiredchanges the rule to “all of”, and assistive technology will announce each as required. - The requirement is stated once, on the group. In the
legendor a hint: “Provide at least one way for us to contact you.” - The error is about the group. “Enter a phone number or an email address”, shown on the group and linked from each member.
- Each member still validates its own format when filled. An email that is filled in must be a valid email, even though email itself is optional.
The core pattern: a group rule plus per-member format rules
type Values = Record<string, string | string[] | undefined>;
const filled = (v: string | string[] | undefined) =>
Array.isArray(v) ? v.length > 0 : typeof v === "string" && v.trim() !== "";
export interface AtLeastOne {
group: string; // id of the group, used for the error slot
members: string[]; // field names
message: string; // names the alternatives
}
export function checkAtLeastOne(values: Values, rule: AtLeastOne) {
return rule.members.some((m) => filled(values[m])) ? null : { group: rule.group, message: rule.message };
}
// Member format rules run ONLY when the member is filled.
const formatRules: Record<string, (v: string) => string | null> = {
phone: (v) => (/^[+\d][\d\s()-]{6,}$/.test(v.trim()) ? null : "Enter a phone number, like 07700 900123."),
email: (v) => (/^[^\s@]+@[^\s@]+\.[^\s@.]+$/.test(v.trim()) ? null : "Enter an email like [email protected]."),
};
export function validateContact(values: Values) {
const fieldErrors: Record<string, string> = {};
for (const [name, rule] of Object.entries(formatRules)) {
const v = values[name];
if (typeof v === "string" && filled(v)) {
const e = rule(v);
if (e) fieldErrors[name] = e;
}
}
// Only report the group rule if no member has its own error: a malformed
// email already tells the user what to fix.
const group = Object.keys(fieldErrors).length ? null : checkAtLeastOne(values, {
group: "contact", members: ["phone", "email"],
message: "Enter a phone number or an email address.",
});
return { fieldErrors, groupError: group };
}
<fieldset aria-describedby="contact-hint contact-error">
<legend>How can we contact you?</legend>
<p id="contact-hint">Provide at least one. We'll only use it about your order.</p>
<p id="contact-error" class="group-error" hidden></p>
<label for="phone">Phone number (optional)</label>
<input id="phone" name="phone" type="tel" autocomplete="tel">
<label for="email">Email address (optional)</label>
<input id="email" name="email" type="email" autocomplete="email">
</fieldset>
Step-by-step walkthrough
- State the requirement in the group, up front. The
legendasks the question and a hint says “Provide at least one”, linked to thefieldsetwitharia-describedbyso screen readers hear it on entering the group. - Do not mark members required. Label them as optional, or leave them unmarked, depending on your convention — see indicating required and optional fields.
- Validate each member’s format only when filled. Optional does not mean unchecked; a filled field must be valid.
- Evaluate the group rule over all members. It passes if any member is filled — for checkbox groups, if any box is checked.
- Show the group error on the group. Unhide the error paragraph inside the
fieldsetand list it in the error summary, linking to the first member. - Clear it when any member is filled. Re-run the rule on every member’s change, using the dependency approach from revalidating dependent fields when a source changes.
Why “optional” labels matter here
With no required markers on either field, users need another signal that the group as a whole is not optional. The legend and hint provide it, but labelling each member “(optional)” strengthens it further: it tells users they do not have to fill in both, which is the most common misreading of these groups. Forms that mark all optional fields as “optional” (rather than marking required ones with an asterisk) handle this case naturally, because the group-level instruction is the only statement of requirement on the page and it stands out.
Failure modes and edge cases
1. Both fields marked required
required on both inputs makes native validation (and screen readers) demand both. The group rule belongs in code; the members stay optional in markup.
2. Error attached to one member
“Phone is required” on the phone field tells an email-preferring user they must give a phone number. The message must name the alternatives and live on the group.
3. Group error plus member error
If the email is malformed and the phone empty, showing both “Enter a phone number or an email address” and “Enter a valid email” is confusing. Suppress the group error when a member has its own error, as the code above does.
4. Checkbox groups
For “choose at least one”, the members are checkboxes sharing a name; the rule checks the array’s length. Put the error on the fieldset, per accessible errors for radio and checkbox groups.
5. Schemas
In Zod, express the rule with superRefine on the object, adding the issue at a group path such as ["contact"] and mapping it to the group slot. A z.union of “has phone” and “has email” objects technically works but produces unhelpful union errors.
Verification checklist
Frequently Asked Questions
Should I ask users which contact method they prefer instead?
Often that is better: a radio group “How should we contact you?” with the matching field revealed makes one field conditionally required, which is simpler to understand and validate. Use “at least one of” when you genuinely want any combination.
How should "at least two of" work?
The same way, with a count: filled members must be at least two, and the message states the number (“Choose at least two topics”). Keep the per-member format rules unchanged.
Does aria-required help on the group?
aria-required is not supported on fieldset or group roles in a way screen readers reliably announce. State the requirement in the legend or hint text instead, which every user can see and hear.