How to Conditional Validate with Yup
Use Yup when and test to express conditional rules that depend on sibling fields, so the schema encodes cross-field logic in one declarative place.
On this page
Introduction
The hardest part of express cross-field rules is not writing the code — it is deciding what the code is allowed to reject. A Yup setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in Yup, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Choosing Yup for express cross-field rules is a trade, not a winner. You give up some flexibility to get a hard boundary, and the exchange pays off when the boundary catches a class of bugs that would otherwise surface at 3 a.m. Document the trade at the call site so the next reader does not undo the guard thinking it is a bug. The reader who does not know why the guard exists is the reader who removes it.
The sections below walk through the concrete decisions: where to put the yup check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Define the object schema
Define the object schema is the first decision because it determines what the rest of the Yup pipeline can assume. A wrong choice here turns into express cross-field rules under load, and the invariant you thought Yup guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Define the object schema is the one most teams skip: drive yup to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
import * as yup from 'yup';
const S = yup.object({
hasPhone: yup.boolean(),
phone: yup.string().when('hasPhone', {
is: true, then: s => s.required(),
}),
});
The javascript above is the shape to ship for express cross-field rules. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Add a when clause
The mechanism behind Add a when clause is straightforward in Yup, but the consequences ripple. A wrong choice here turns into express cross-field rules under load, and the invariant you thought Yup guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Add a when clause is the one most teams skip: drive yup to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Compose test functions
Compose test functions is where most Yup implementations leak performance. A wrong choice here turns into express cross-field rules under load, and the invariant you thought Yup guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Compose test functions is the one most teams skip: drive yup to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('phone required when hasPhone', async () => {
await expect(S.validate({ hasPhone: true })).rejects.toThrow();
});
Pin the behavior with this test. A Yup setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Handle null siblings
The hard part of Handle null siblings is not the happy path — it is what happens when yup fails halfway. A wrong choice here turns into express cross-field rules under load, and the invariant you thought Yup guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Handle null siblings is the one most teams skip: drive yup to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Migrate to Zod if needed
Migrate to Zod if needed should be observable without a debugger. A wrong choice here turns into express cross-field rules under load, and the invariant you thought Yup guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Migrate to Zod if needed is the one most teams skip: drive yup to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test conditional branches
Test conditional branches is a trade, not a winner. A wrong choice here turns into express cross-field rules under load, and the invariant you thought Yup guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test conditional branches is the one most teams skip: drive yup to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in express cross-field rules all reduce to one mistake: treating Yup as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the yup boundary so express cross-field rules enters unvalidated.
- Assuming Yup is fast without measuring the tail latency under load.
- Letting express cross-field rules fail silently instead of failing loudly with a metric.
- Testing only the happy path so the yup failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: Yup fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives yup to the failing condition and assert the system degrades gracefully. Then express cross-field rules is a property you maintain, not a feature you hope for.
FAQ
- Is when lazy or eager?
- Eager — it runs on every validate call. For expensive checks, gate them inside the then branch.
- How debug deep conditions?
- Cast to a debug schema and log the resolved spec, or split into named test functions.
Report a problem with this articleOpens a pre-filled issue on GitHub.