Skip to main content

Comparing Joi 17 vs Yup 1.4 Validation APIs

Choosing between Joi 17 and Yup 1.4 often comes down to one concrete constraint: whether the schema lives on the server only or is shared with a React form. Beyond that, the two libraries diverge on type inference, coercion defaults, async rule design, error shapes, and extension APIs in ways that cause real bugs when you pick the wrong one or port a schema without accounting for the differences. This guide extends Joi and Yup for Legacy Systems by placing the two APIs directly side by side so the trade-offs are visible in code, not abstractions.

Joi 17 vs Yup 1.4 API comparison matrix Six-row matrix comparing Joi 17 and Yup 1.4 across schema syntax, TypeScript inference, coercion control, async support, error shape, and ecosystem fit. Joi is shown in copper-dark, Yup in pumpkin.

Dimension Joi 17 Node 12+ · 25 kB gzip Yup 1.4 Node 14+ · 12 kB gzip

Schema syntax object root Joi.object({ field: Joi.string() }) yup.object({ field: yup.string() }) chain methods on each type same chain style; mixed() for any TypeScript inference static type none — write interface separately InferType<typeof schema> duplicate type + schema required wide on mixed/oneOf; needs cast Coercion / casting disable convert: false in options .strict() on schema or { strict:true } bake into Joi.defaults() no global default; per-schema only Async rules entry point .external(fn) + validateAsync() async .test(fn) + validate() sync validate silently skips external validateSync throws TypeError Error shape failure array error.details[].{ path, type, message } err.inner[].{ path, type, message } path is array; type is dotted rule path is dot-string; inner empty unless abortEarly:false Ecosystem fit primary use Node.js backend APIs, queue payloads React forms (Formik, RHF) + backend richer built-ins, heavier bundle lighter; native form-library adapter

Framing the Choice

The superficial API of Joi 17 and Yup 1.4 looks identical: both expose a chainable schema object, both validate a raw value against declared rules, and both return structured errors. The differences that matter in practice are narrower but sharper.

Where Joi 17 has a clear edge:

  • Built-in validators for ISBN, LUHN, hostname, URI, and domain with TLD verification — Yup’s string() covers email and URL only.
  • A formal extension API (Joi.extend()) that registers new types with their own error message keys and compile-time chain methods.
  • The ability to bake global defaults — coercion off, unknown keys forbidden — into a single Joi.defaults() call so every schema in the codebase inherits them automatically.
  • No external peer dependency. Joi ships as a single CommonJS package with no runtime deps, which matters on constrained environments.

Where Yup 1.4 has a clear edge:

  • InferType<typeof schema> gives a (partial) TypeScript type from the schema, cutting the duplicate interface-plus-schema maintenance that Joi forces.
  • A 12 kB gzipped browser build versus Joi’s ~25 kB — significant when validation runs client-side on mobile devices.
  • Native integration with Formik via validationSchema and with React Hook Form via @hookform/resolvers/yup. The form libraries call Yup’s API directly; Joi requires a wrapper.
  • A single .test() method covers both synchronous and async custom rules, so the API surface is smaller.

When neither is the right answer: If the project is on TypeScript and the legacy constraint is gone, runtime validation with Zod infers airtight static types without any of the duplication either library requires. Use Joi or Yup when you are locked into JavaScript, when Zod is too large a migration in one step, or when a form library mandates Yup.

Side-by-Side Equivalent Schemas

The User shape below appears in both libraries with identical semantics — strict coercion off, unknown keys rejected, nullable email, polymorphic status. Annotations explain where the APIs diverge.

Joi 17

// validation/user.joi.ts  —  Joi 17.13.3, TypeScript 5.4
import Joi from 'joi';

// Bake coercion-off and strict-keys into every schema in this module.
// This is the Joi-specific global-defaults pattern; Yup has no equivalent.
const joi = Joi.defaults((schema) =>
  schema.options({ convert: false, allowUnknown: false }),
);

// The TypeScript interface must be declared separately — Joi infers nothing.
interface User {
  id: number;
  email: string | null;
  status: 'active' | 'inactive' | 0 | 1;
  createdAt: string;
}

export const userSchema = joi.object<User>({
  id:        joi.number().integer().positive().required(),
  //         ^ .number() — no coercion because convert:false is on
  email:     joi.string().email({ tlds: false }).allow(null).optional(),
  //                                             ^ explicit null; .allow(null)
  //                                               does not strip tlds check
  status:    joi.alternatives().try(
               joi.string().valid('active', 'inactive'),
               joi.number().integer().valid(0, 1),
             ).required(),
  //         ^ polymorphic: string OR number. Yup uses mixed().oneOf() instead.
  createdAt: joi.string().isoDate().required(),
});
// Validate: const { error, value } = userSchema.validate(body, { abortEarly: false });
// Async:    const value = await userSchema.validateAsync(body, { abortEarly: false });

Yup 1.4

// validation/user.yup.ts  —  Yup 1.4.0, TypeScript 5.4
import * as yup from 'yup';

// No global defaults object in Yup. Strictness is applied per schema.
// .strict() disables coercion; .noUnknown() rejects extra keys.
export const userSchema = yup
  .object({
    id:        yup.number().integer().positive().required(),
    //         ^ .number() still casts without .strict(); .strict() on the object
    //           propagates to children in Yup 1.x.
    email:     yup.string().email().nullable().optional(),
    //                              ^ .nullable() — a separate method, not .allow(null)
    status:    yup
                 .mixed<'active' | 'inactive' | 0 | 1>()
                 //     ^ TypeScript generic narrows InferType for this field
                 .oneOf(['active', 'inactive', 0, 1] as const)
                 //      ^ CRITICAL: null is NOT implicitly included in oneOf.
                 //        Add null explicitly if the field can be null.
                 .required(),
    createdAt: yup.string().required(),
    //         ^ No built-in isoDate(); use .matches(isoRegex) or a .test() instead.
  })
  .strict()       // coercion off for all child fields
  .noUnknown();   // reject keys not in the shape

// Static type is inferred — no separate interface needed for simple schemas.
type User = yup.InferType<typeof userSchema>;
// Caveat: status infers as ('active'|'inactive'|0|1|undefined) — the .required()
// does not remove undefined from the inferred type. You may need a manual cast.

// Validate: await userSchema.validate(body, { abortEarly: false });
// Sync:     userSchema.validateSync(body, { abortEarly: false });
// NEVER call validateSync on a schema that has an async .test() — it throws.

Three structural differences are visible side by side:

  1. Polymorphic fields — Joi’s .alternatives().try() composes independent sub-schemas and uses the first passing branch. Yup’s mixed().oneOf([...]) accepts a flat list of allowed literals but cannot express “must be a valid email string OR a numeric ID” — you need a .test() for that.
  2. ISO date validation — Joi has .isoDate() as a built-in method on string(). Yup has no equivalent; you write .matches(/^\d{4}-\d{2}-\d{2}T/) or a custom .test().
  3. null in oneOf — Joi’s .allow(null) is additive and independent of .valid(). Yup’s .oneOf() is the complete allowed set; if null is valid it must appear in the array alongside the other literals.

Comparison Table

Dimension Joi 17 Yup 1.4
TypeScript inference None — write interface separately InferType<typeof schema>; weak on mixed
Coercion default ON — casts strings to numbers/dates aggressively ON — casts numeric strings to numbers, trims strings
Disable coercion convert: false in options or Joi.defaults() .strict() on the schema (no global option)
Reject unknown keys allowUnknown: false (default in Joi.defaults) .noUnknown() on the object schema
Polymorphic field .alternatives().try(schemaA, schemaB) mixed().oneOf([...]) (literals only)
ISO date built-in joi.string().isoDate() None — use .matches() or .test()
null literal .allow(null) independent of .valid() Must be listed inside .oneOf([..., null])
Custom sync rule .custom(fn, label) .test(name, message, fn)
Custom async rule .external(async fn) async .test(name, message, async fn)
Async entry point schema.validateAsync() await schema.validate()
Sync entry point with async rule Skips .external() silently Throws TypeError — fail-loud
Error failure array error.details — path is string[] err.inner — path is dot-notation string; populated only with abortEarly: false
Rule error code type e.g. "number.base", "any.required" type e.g. "typeError", "required"
Extension API Joi.extend(factory) — registers new type with chain methods Inline .test() only; no custom type registration
Bundle size (gzip) ~25 kB ~12 kB
Formik integration Manual adapter required validationSchema={yupSchema} native prop
React Hook Form @hookform/resolvers/joi @hookform/resolvers/yup (more maintained)
Node.js minimum 12 14

Coercion and Casting in Detail

Coercion is the most common source of bugs when switching between the two libraries. Both are ON by default. The mechanics differ.

Joi 17 coercion happens inside .validate() before the type check. A Joi.number() field receiving the string "42" returns 42 (integer) and no error. A Joi.date() field receiving "2026-06-20" returns a Date object. You get back a different value than you passed in. This is useful in templates and CLI tools; it is dangerous on an API boundary because upstream callers learn they can send the wrong type.

Yup 1.4 coercion runs its cast phase before validate. yup.number() receiving "42" casts to 42. yup.string() receiving 42 calls .toString() and returns "42". .boolean() coerces "true" and "false" to their boolean equivalents. Calling .strict() skips the cast phase entirely — the value is tested as-is.

To disable both globally in a project:

// joi-instance.ts — Joi 17 global coercion off
import Joi from 'joi';
export const joi = Joi.defaults((schema) =>
  schema.options({ convert: false, allowUnknown: false }),
);
// Import this `joi` everywhere instead of bare `import Joi from 'joi'`.

// yup-helpers.ts — Yup has no defaults; enforce per object
export function strictObject<S extends yup.AnyObjectSchema>(shape: S): S {
  return (shape.strict() as unknown as S).noUnknown() as unknown as S;
}

Yup’s lack of a global defaults API is the main operability gap. In a large codebase where dozens of schemas are defined across modules, forgetting .strict() on one schema silently re-enables coercion. A lint rule or a wrapper like strictObject above closes that gap.

Async Validation

The async APIs differ in the failure mode when you call the wrong entry point — which matters because the wrong choice produces either silent incorrect results (Joi) or a runtime exception (Yup).

For full examples of async patterns including uniqueness checks and rate-limited I/O, see handling async validation in Joi schemas.

// Joi 17: async rule via .external()
import Joi from 'joi';
import { userRepo } from './db';

const joiSchema = Joi.object({
  email: Joi.string()
    .email({ tlds: false })
    .external(async (value) => {
      // ONLY runs under validateAsync(). Under validate() this is skipped silently.
      if (await userRepo.emailExists(value)) {
        throw new Error('email already registered');
      }
    }),
});

// Correct call:
const value = await joiSchema.validateAsync(body, { abortEarly: false });
// WRONG — external rule is silently skipped, email uniqueness is never checked:
const { error } = joiSchema.validate(body, { abortEarly: false });
// Yup 1.4: async rule via async .test()
import * as yup from 'yup';
import { userRepo } from './db';

const yupSchema = yup.object({
  email: yup
    .string()
    .email()
    .test('unique-email', 'email already registered', async (value) =>
      value ? !(await userRepo.emailExists(value)) : true,
    ),
});

// Correct call:
await yupSchema.validate(body, { abortEarly: false });
// WRONG — throws TypeError: "Yup: .validateSync() cannot be used on schemas
// with async tests." The failure is loud, unlike Joi's silent skip.
yupSchema.validateSync(body, { abortEarly: false }); // throws

Yup’s fail-loud behaviour on validateSync is a better safety property than Joi’s silent skip, provided your test suite hits that path. In practice, the Joi silent-skip bug surfaces in production rather than tests because a validate call in a route handler was never upgraded to validateAsync after an .external() rule was added.

Error Shapes

Both libraries return structured errors, but the shape differs enough to break a normalisation function that assumes one format fits both.

// Normalise Joi 17 errors to { field, rule, message }[]
import type { ValidationError as JoiValidationError } from 'joi';

function normaliseJoi(err: JoiValidationError) {
  return err.details.map((d) => ({
    field:   d.path.join('.'),   // path is string[] — join to dot-notation
    rule:    d.type,             // e.g. "number.base", "string.email", "any.required"
    message: d.message,         // already includes the label
  }));
}

// Normalise Yup 1.4 errors to the same shape
import type { ValidationError as YupValidationError } from 'yup';

function normaliseYup(err: YupValidationError) {
  // err.inner is empty unless abortEarly:false was passed to validate().
  // When abortEarly:true (the default) only err itself holds the first failure.
  const errors = err.inner.length > 0 ? err.inner : [err];
  return errors.map((e) => ({
    field:   e.path ?? '',       // path is dot-notation string or undefined for root
    rule:    e.type ?? 'unknown',
    message: e.message,
  }));
}

Key differences to handle:

  • path type — Joi’s path is (string | number)[]; join to get "address.lines.0". Yup’s path is already a dot-notation string like "address.lines[0]" (bracket notation for array indices).
  • err.inner population — Yup only populates inner when abortEarly: false is set. Under the default abortEarly: true, only the first error is available directly on err. Joi’s error.details always contains the first error regardless of abortEarly; with abortEarly: false it contains all of them.
  • Rule code naming — Joi’s type is namespaced (number.base, string.isoDate) matching the schema builder chain. Yup’s type is flatter (typeError, required, email, min) and sometimes undefined for custom .test() failures where no type was passed.

Map both to a consistent format before building your 422 response body. The designing robust error response contracts guide covers the RFC 7807 envelope these details populate.

Recommendation by Scenario

Backend-only Node.js service, no TypeScript migration planned: Use Joi 17. The global Joi.defaults() enforces coercion-off across the whole codebase in one line, the built-in validators cover more formats, and the .external() + validateAsync() separation makes async intent explicit.

Backend service with TypeScript and incremental Zod migration: Start with Yup 1.4 if the migration is near-term — InferType provides partial type inference today, and the API is closer to Zod’s than Joi’s is, making the eventual migration to Zod easier. If the migration is years away, Joi’s richer built-ins outweigh the inference gap.

React form shared with a backend route: Use Yup 1.4 unconditionally. Formik’s validationSchema prop and React Hook Form’s @hookform/resolvers/yup are native integrations. Writing a Joi adapter for the same purpose is a wrapper you own and maintain.

Isomorphic validation (SSR + client bundle): Use Yup. The ~12 kB gzipped bundle is half of Joi’s. On slow mobile connections validating before a round-trip matters; bundle budget does not.

Highly constrained legacy environment (Node 12, CommonJS only, no bundler): Joi 17 is the only option — Yup 1.4 requires Node 14 and ships an ESM build that may need a shim in CommonJS-only environments.

Verification

After wiring either library into an Express route, confirm coercion is genuinely off before shipping. The test below fails if convert: false or .strict() was forgotten:

// schema-coercion.test.ts — run with: npx jest schema-coercion
import { userSchema as joiSchema } from './validation/user.joi';
import { userSchema as yupSchema } from './validation/user.yup';

const validPayload = {
  id: 42,
  email: 'test@example.com',
  status: 'active',
  createdAt: '2026-06-20T00:00:00Z',
};

// ── Joi tests ──────────────────────────────────────────────────────────────
describe('Joi 17 userSchema', () => {
  it('accepts a valid payload', () => {
    const { error } = joiSchema.validate(validPayload);
    expect(error).toBeUndefined();
  });

  it('rejects a string id when coercion is off', () => {
    const { error } = joiSchema.validate({ ...validPayload, id: '42' });
    expect(error?.details[0].type).toBe('number.base');
    // If this assertion fails, convert:false was NOT applied to the schema.
  });

  it('rejects unknown keys', () => {
    const { error } = joiSchema.validate({ ...validPayload, extra: true });
    expect(error?.details[0].type).toBe('object.unknown');
  });
});

// ── Yup tests ──────────────────────────────────────────────────────────────
describe('Yup 1.4 userSchema', () => {
  it('accepts a valid payload', async () => {
    await expect(yupSchema.validate(validPayload)).resolves.toBeDefined();
  });

  it('rejects a string id when strict is on', async () => {
    await expect(
      yupSchema.validate({ ...validPayload, id: '42' }),
    ).rejects.toMatchObject({ type: 'typeError' });
    // If this resolves, .strict() was NOT applied.
  });

  it('rejects unknown keys', async () => {
    await expect(
      yupSchema.validate({ ...validPayload, extra: true }, { abortEarly: false }),
    ).rejects.toMatchObject({ message: expect.stringContaining('extra') });
  });
});

A passing run for both suites prints 6 passed. If the string-id test passes (resolves instead of rejects), trace back to the schema definition and confirm the coercion-disable was applied.

Edge Cases and Caveats

oneOf and null in Yup are a silent trap. yup.string().oneOf(['a', 'b']) rejects null even if the field is declared .nullable(). The nullable call tells Yup that null is a valid type, but oneOf is a separate constraint on the allowed value set. You must write .oneOf(['a', 'b', null]) explicitly. Joi’s .valid('a', 'b').allow(null) chains independently so null is always additive and never needs to appear inside .valid().

Yup InferType for optional fields includes undefined in the inferred type. A field declared .optional() infers as T | undefined. For fields declared .required(), Yup’s inference does not remove undefined — the type still includes it. This means User.status infers as 'active' | 'inactive' | 0 | 1 | undefined even with .required(). Callers see an inaccurate type. You must add a manual as cast or wrap the inferred type in Required<>.

Joi .alternatives().try() stops at the first passing branch and does not backtrack. If a value matches the first sub-schema partially (no error yet) and then fails a later rule in that branch, Joi does not try the next branch. Order matters: put the most discriminating sub-schema first. This is not a Yup concern because mixed().oneOf() is a flat equality check with no branching.

Frequently Asked Questions

Does Yup InferType give reliable TypeScript types from a schema?

Partially. Yup InferType infers the output shape of a schema but produces wide types for mixed() and oneOf fields — you often need a cast or a manually narrowed type alongside it. Joi 17 has no equivalent and requires a separate TypeScript interface.

Which library coerces more aggressively by default?

Both coerce types by default, but Joi 17 is more aggressive: it casts strings to numbers, dates, and booleans when the schema type implies it. Yup 1.4 casts numbers from numeric strings and trims strings. Disable coercion explicitly in both before using either for API contract validation.

Can I mix synchronous and async validation rules in the same schema?

Yes in both libraries, but the entry point matters. In Joi 17 you must call validateAsync to run external async rules; sync validate silently skips them. In Yup 1.4 any async test() makes the schema async-only — calling validateSync throws a TypeError.

Which library is smaller for browser bundle budgets?

Yup 1.4 is smaller at roughly 12 kB gzipped. Joi 17’s browser build ships around 25 kB gzipped. For form validation in a React app where bundle size matters, Yup is the standard choice. For Node.js-only backend services the size difference is irrelevant.

What does Joi error.details contain versus Yup err.inner?

Joi error.details is an array of objects with message, path (array of keys), type (the rule code like number.base), and context. Yup err.inner is an array of ValidationError instances, each with message, path (dot-notation string), and type. You must normalise both into a consistent shape before sending a 422 response.

When should I choose Joi over Yup for a backend service?

Choose Joi 17 when you need richer built-in string validators (ISBN, LUHN, hostname), when you want a self-contained extension API (extend) rather than inline test callbacks, or when the service is Node-only and bundle size is not a concern. Choose Yup when the same schema is shared between a React form (Formik or React Hook Form) and a backend route.