Joi: Switch-like structures are very awkward to create with alternatives

Created on 9 Mar 2017  路  8Comments  路  Source: sideway/joi

The current alternatives API makes it very cumbersome to emulate something like a switch statement.

In my case, I'm trying to validate a polymorphic payload that can conform to one of several schemas, which are identified by a simple type parameter in the object, ie:

[{
  type: 'book',
  author: 'Herman Melville'
},
{
  type: 'periodical',
  volume: 6
}, {
  type: 'music',
  genre: 'jazz'
}, {
  type: 'film',
  aspectRatio: '16:9'
}];

Achieving this with the current Joi API requires a heavily nested series of Joi.when expressions.

payload: Joi.when('type', {
        is: 'book',
        then: bookSchema,
        otherwise: Joi.when('type', {
            is: 'periodical',
            then: periodicalSchema,
            otherwise: Joi.when('type', {
                is: 'music',
                then: musicSchema,
                otherwise: Joi.when('type', {
                    is: 'film',
                    then: filmSchema
                })
            })
        })
})

A proposed API could use a series of key/value pairs to identify branches, and optionally provide a fallback:

Joi.alternatives().switch('type', {
   book: bookSchema,
   periodical: periodicalSchema,
   music: musicSchema,
   film: filmSchema
}, defaultSchema);

This wouldn't allow for any kind of complicated branching or ordering, but seems like it would offer a significantly cleaner API for a common use-case.

feature

Most helpful comment

@kimmobrunfeldt I believe you can achieve that by modifying your schema to look like this:

const payloadSchema = Joi.array().items(
  Joi.alternatives()
    .when(Joi.object({type: 'book'}).unknown(), { then: a.bookSchema })
    .when(Joi.object({type: 'periodical'}).unknown(), { then: a.periodicalSchema })
    .when(Joi.object({type: 'music'}).unknown(), { then: a.musicSchema })
)

All 8 comments

More complicated branches could also be handled with an alternative signature for that same API that allows for ordering and more complicated matcher expressions:

Joi.alternatives().switch('ipAddress', [
  [Joi.string.ip({ version: 'ipv4' }), ipv4Schema],
  [Joi.string.ip({ version: 'ipv6' }), ipv6Schema]
  [Joi.any(), defaultSchema]
]);

where we provide a list of Joi expressions to validate against the field, and a schema to use if the field matches (using the first one that matches). Or, put another way:

.alternatives(prop, [ [matcher, validator], ...])

Taking it a step further, the matchers don't all need to be inspecting the same property:

Joi.alternatives().switch([
  ['ipAddress', Joi.string.ip({ version: 'ipv4' }), ipv4ServerSchema],
  ['ipAddress', Joi.string.ip({ version: 'ipv6' }), ipv6ServerSchema],
  ['uri', Joi.string.uri(), uriBasedServerSchema]
]);
.alternatives([ [prop, matcher, validator], ...])

Taking this another small step further, we don't necessarily always need to validate the _first_ thing that matches, and could provide alternative methods for how to evaluate the ordered list of rules and matchers:

// the payload must validate against the schema that corresponds to the FIRST matcher that evaluates to true
.alternatives.switch.firstMatching([]) 

// the payload must validate against the schemas that correspond to EVERY matcher that evaluates to true
.alternatives.switch.allMatching([]) 

// the payload must validate to the schema of AT LEAST ONE matcher that evaluates to true
.alternatives.switch.anyMatching([])

// same as anyMatching, but the payload must have at least one match from the list
.alternatives.switch.someMatching([])

To avoid all of the nesting that you are currently doing you can actually specify multiple when conditions on different lines.

Here's an example:

const schemaAlternative = {
    a: Joi.string().valid('aa','ab', 'ac').required(),
    b: Joi.alternatives()
        .when('a', {is: 'aa', then: Joi.string().valid('ba', 'bb').required()})
        .when('a', {is: 'ab', then: Joi.string().valid('bc').required()})
        .when('a', {is: 'ac', then: Joi.forbidden()})
};

I don't see anything that's not already implemented with when in this issue, you just missed the point that it acts like an if-else when either then or otherwise are not specified. @DavidTPate already answered the question and I have nothing to add, feel free to reply if you disagree.

I may have not gotten something, but to me it seems what @schmod asked is a bit different than what @DavidTPate's solution solves. I'm also facing the same problem where the payload is an array of polymorphic objects. Did you notice that in his example, type attribute is on the same level as all the other attributes?

The solution provided by @DavidTPate would be a good fit if all of the objects would have a unified structure, e.g.:

[{
  type: 'book',
  data: {
    author: 'Herman Melville'
  }
}, {
  type: 'periodical',
  data: {
    volume: 6
  }
}, {
  type: 'music',
  data: {
    genre: 'jazz'
  }
}];

However this is not the case, and at least I couldn't figure out how to solve this. As an example what I tried to do so you get the idea (I know the code doesn't work):

const bookSchema = Joi.object({
  type: Joi.string().valid('book'),
  author: Joi.string(),
});
const periodicalSchema = Joi.object({
  type: Joi.string().valid('periodical'),
  volume: Joi.number().integer(),
});
const musicSchema = Joi.object({
  type: Joi.string().valid('music'),
  genre: Joi.string(),
});
const payloadSchema = Joi.array().items(
  Joi.alternatives()
    .when('type', { is: 'book', then: bookSchema })
    .when('type', { is: 'periodical', then: periodicalSchema })
    .when('type', { is: 'music', then: musicSchema })
);
const result = Joi.validate(payload, payloadSchema);

It results to ValidationError: "value" at position 0 fails because ["0" not matching any of the allowed alternatives error.

@kimmobrunfeldt I believe you can achieve that by modifying your schema to look like this:

const payloadSchema = Joi.array().items(
  Joi.alternatives()
    .when(Joi.object({type: 'book'}).unknown(), { then: a.bookSchema })
    .when(Joi.object({type: 'periodical'}).unknown(), { then: a.periodicalSchema })
    .when(Joi.object({type: 'music'}).unknown(), { then: a.musicSchema })
)

@kamilwaheed fantastic, that works! Thank you. 馃檱

I think my code above was not working because behind the scenes is that it tries to check if obj.type === 'book' for the actual array (or some other object?), not the items inside it. That's why the obj.type resolves as undefined, and doesn't match to any of the conditions.

As a recap, here's a working code (same as what @kamilwaheed provided, but fixed the references to schemas). Note: it didn't work with ^[email protected], but upgrading to [email protected] fixed it.

const payloadSchema = Joi.array().items(
  Joi.alternatives()
    .when(Joi.object({ type: 'book' }).unknown(), { then: bookSchema })
    .when(Joi.object({ type: 'periodical' }).unknown(), { then: periodicalSchema })
    .when(Joi.object({ type: 'music' }).unknown(), { then: musicSchema }),
);

Careful with 13 and its node version requirements, hope you read the changes.

This thread has been automatically locked due to inactivity. Please open a new issue for related bugs or questions following the new issue template instructions.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

PaunPrashant picture PaunPrashant  路  3Comments

jamesdixon picture jamesdixon  路  4Comments

kailashyogeshwar85 picture kailashyogeshwar85  路  4Comments

n-sviridenko picture n-sviridenko  路  3Comments

normancarcamo picture normancarcamo  路  3Comments