When building systems that need to handle unpredictable behavior, or even just to build adaptable systems at scale, you need a robust validation strategy.
Schemas are one of the core components of this. It's super important, especially when implementing things like model context protocol (MCP). You need high visibility and interpretability.
In my humble experience, hashing this out ahead of time has paid dividends in development time especially for frontend engineering.
Given a request or other data, we often want to deduce what was incorrect in the shortest time feasible. We typically have a function which accepts some schema object along with the data that we are working with.

I try to play Switzerland on this site when it comes to languages, but here's the general setup. Let me know if you want to see it in a specific tech stack.
We have some notion of primitive types and recursive types. If we hit a primitive type, we validate it directly. If we hit a recursive type, we recursively do the validation routine.
If we find that some properties did not validate correctly, we determine their paths and percolate them back to the previous level of recursion.
Then there's a higher order function of sorts that will handle these paths along with other pertinent data if there are issues. If not we return a valid result, generally using some gratuitous type encoding by middleware or other utils.
async function handle(request: Request<SomeValidInterface>) {
const result = request.data(schema, request);
}
Our utility function will respond and log with a standardized validation model in the case of failure.
Another common issue I see on the frontend side are when you have API wrappers that do encoding and decoding. You should also make sure that these are visible and interpretable.
guard let model = try? JSONDecoder().decode(type.self, from: data) else { throw RequestError.invalidResponse }
If you have something like this in your code, you should consider extending it to something like the following.
do {
let result = try JSONDecoder().decode(type.self, from: data)
return result
} catch DecodingError.keyNotFound(let key, let context) {
var path = context.codingPath.map { $0.stringValue }
path.append(key.stringValue)
throw ModelError.invalidPath(path)
} catch DecodingError.typeMismatch(let key, let context) {
let path = context.codingPath.map { $0.stringValue }
let expected = String(describing: key)
throw ModelError.invalidType(path, expected)
} catch DecodingError.valueNotFound(let key, let context) {
let path = context.codingPath.map { $0.stringValue }
let expected = String(describing: key)
throw ModelError.invalidType(path, expected)
} catch {
throw ModelError.unknown
}
More verbose indeed, but you only need to do this once. Make a few abstractions for the cases you want to handle and shove them in a util.
It will save your ass.