Role based permissions in Express.js
I think I came up with a better and more declarative way using Joi schemas in nested dictionaries.
The idea is that we first figure out these three things:

Is the user editing themselves? (bool)
What type of user is the editor? (admin, etc)
What type of user is being edited? (admin, etc)

And based on those three things, we can lookup the correct Joi schema to use.
So the schema dictionary looks like this for the PATCH route:
// No one is allowed to edit their own user type, everyone can edit their own name.
const selfEditSchemas: { [key in UserType]?: Joi.Schema } = {
  [UserType.superAdmin]: Joi.object({
    userType: Joi.forbidden(),
    name: Joi.string().pattern(/^[a-zA-Z0-9_\-. ]{3,100}$/).optional(),
  }),
  [UserType.admin]: Joi.object({
    userType: Joi.forbidden(),
    name: Joi.string().pattern(/^[a-zA-Z0-9_\-. ]{3,100}$/).optional(),
  }),
  [UserType.user]: Joi.object({
    userType: Joi.forbidden(),
    name: Joi.string().pattern(/^[a-zA-Z0-9_\-. ]{3,100}$/).optional(),
  }),
};

// We only allow admins and super admins to edit others, and we use different schemas
// depending on what type of user they are trying to edit.
const otherEditSchemas: { [key in UserType]: { [key in UserType]?: Joi.Schema } } = {
  [UserType.superAdmin]: {
    [UserType.admin]: Joi.object({ // (superAdmin can edit admins according to this schema)
      userType: Joi.string().valid(...Object.values(UserType)).optional(),
      name: Joi.string().pattern(/^[a-zA-Z0-9_\-. ]{3,100}$/).optional(),
    }),
    [UserType.user]: Joi.object({ // (superAdmin can edit regular users according to this chema)
      userType: Joi.string().valid(...Object.values(UserType)).optional(),
      name: Joi.string().pattern(/^[a-zA-Z0-9_\-. ]{3,100}$/).optional(),
    }),
  },
  [UserType.admin]: {
    [UserType.user]: Joi.object({ // (admin can edit regular users according to this schema)
      userType: Joi.forbidden(), // (admin cannot promote regular users)
      name: Joi.string().pattern(/^[a-zA-Z0-9_\-. ]{3,100}$/).optional(),
    }),
  },
  [UserType.user]: {}, // (user is not allowed to edit anyone else)
};

And now the logic for validating edits becomes a lot simpler and less error-prone IMO:
function validateUserEdit(req: express.Request, res: express.Response, next: express.NextFunction) {
  const userReq = req as UserRequest;

  const isSelf = userReq.user.id === userReq.otherUser.id;
  const schema = isSelf
    ? selfEditSchemas[userReq.user.userType]
    : otherEditSchemas[userReq.user.userType][userReq.otherUser.userType];

  if (!schema) {
    return res.status(403).json({
      message: 'Not allowed to edit that user.',
      code: ErrorCodes.FORBIDDEN_TO_EDIT_USER,
    });
  }

  const validateRes = schema.validate(req.body, { stripUnknown: true });
  if (validateRes.error) {
    res.status(400).json({
      message: `Invalid arguments: ${validateRes.error}`,
      code: ErrorCodes.INVALID_ARGUMENTS,
    });
  } else {
    req.body = validateRes.value;
    next();
  }
}