I am trying to understand whether the error is with JSON format (missing comma and whatnot), or a missing required field (model validation), and return to the user one of the defined custom errors without showing the error message that ModelState has.
if (!ModelState.IsValid)
{
var errors = ModelState.Values.SelectMany(x => x.Errors);
}
I've tried looking for exceptions for these errors
var jsonErrors = errors.Where(e => e.Exception is JsonException).ToList();
However Exception property is always null. How come?
The only way right now it seems to have some logic filtering on ErrorMessage property, which just seems strange.

ModelState.IsValid is checking for validation errors. Exception is null because these are validation errors, not the exceptions thrown during json deserialization.
You can catch Json Exceptions in the code above, when you were in deserialization step.