A studio apartment and a warehouse do not share a form, so unit fields are defined as data: the operator adds a row, and every unit of that property type gains a field. The values live in one JSONB column.

That makes the frontend flexible. It does nothing for correctness — Postgres will happily store {"bedrooms": "yes"} in JSONB. So the schema is enforced again, for real, on the way in:

import AppError from "../../middlewares/appError.js";

// Validates a unit's dynamic `attributes` JSONB payload against the
// unit_attribute_definitions for its property's property_type — the
// server-side half of the dynamic schema (the other half drives the
// frontend form). Plain validation function, not a runtime-built Zod
// schema — simpler to read and to test.
export function validateUnitAttributes(attributes, definitions) {
  if (
    typeof attributes !== "object" ||
    attributes === null ||
    Array.isArray(attributes)
  ) {
    throw new AppError("attributes must be an object", 400);
  }

  for (const def of definitions) {
    const value = attributes[def.attribute_key];
    const isEmpty = value === undefined || value === null || value === "";

    if (def.is_required && isEmpty) {
      throw new AppError(`"${def.label}" is required`, 400);
    }
    if (isEmpty) continue;

    if (def.data_type === "number" && typeof value !== "number") {
      throw new AppError(`"${def.label}" must be a number`, 400);
    }
    if (def.data_type === "boolean" && typeof value !== "boolean") {
      throw new AppError(`"${def.label}" must be true or false`, 400);
    }
    if (def.data_type === "text" && typeof value !== "string") {
      throw new AppError(`"${def.label}" must be text`, 400);
    }
    if (def.data_type === "select") {
      const allowed = (def.options ?? []).map((opt) => opt.value ?? opt);
      if (!allowed.includes(value)) {
        throw new AppError(
          `"${def.label}" must be one of: ${allowed.join(", ")}`,
          400
        );
      }
    }
  }

  // Reject keys the schema doesn't define — keeps the payload honest to
  // the property type's schema rather than silently accepting arbitrary
  // JSON alongside it.
  const allowedKeys = new Set(definitions.map((d) => d.attribute_key));
  for (const key of Object.keys(attributes)) {
    if (!allowedKeys.has(key)) {
      throw new AppError(
        `Unknown attribute "${key}" for this property type`,
        400
      );
    }
  }
}

Two passes, deliberately. The first checks that required fields arrived and present fields have the right type. The second rejects any key the schema does not define.

Without that second pass, a client could attach arbitrary JSON alongside the real attributes and Postgres would keep it forever — "dynamic schema" quietly becoming "no schema" the moment nothing checks the edges.

It is also a plain function over two arrays rather than a runtime-compiled validator. The rules are simple enough that the machinery would cost more to read than it saves to write.