wandres.dev
PROPIEDADES TIPADAS · @property y el registro

Cuando el valor no encaja con la sintaxis

Qué destino tiene un valor rechazado por una propiedad registrada, por qué el error se contiene en vez de propagarse, y los casos límite que cambian de significado al registrar.

⏱ 16 min

Registrar una custom property no impide que alguien le asigne una barbaridad: cualquier declaración --x: loquesea sigue siendo sintácticamente válida y sigue entrando en la cascada. Lo que cambia es qué ocurre después. Sin registro, la barbaridad se guarda y contamina a todos los que la usen; con registro, se rechaza en la propia propiedad y ninguno de sus consumidores se entera. Y de paso, registrar altera el significado de initial, que es la fuente de sorpresas más silenciosa de todo el asunto.

🎯 Al terminar esta lección sabrás
  • Predecir el valor final de una propiedad registrada a la que se asigna algo inválido.
  • Contrastar la contención del error con su propagación en el caso sin registrar.
  • Reconocer los tres casos límite que cambian de significado al registrar.
  • Diagnosticar en el inspector una asignación rechazada.

El destino de un valor que no encaja

La comprobación no ocurre al analizar sino al computar, igual que la sustitución de var(). Cuando el valor no encaja con la sintaxis registrada, la custom property queda inválida en tiempo de computación, y su valor pasa a ser el heredado si se registró con inherits: true, o el initial-value si se registró con inherits: false.

@property --radio {
  syntax: "<length>";
  inherits: false;
  initial-value: 8px;
}

.tarjeta {
  --radio: 1.5rem;
  border-radius: var(--radio);      /* 1.5rem */
}

.rota {
  --radio: muy-redondo;             /* rechazado */
  border-radius: var(--radio);      /* 8px, el initial-value */
}

Es exactamente la semántica de unset, aplicada a la custom property en lugar de a la propiedad que la consume. Y esa diferencia de destinatario lo es todo.

Contención frente a propagación

flowchart TB
A[Alguien asigna un valor malo] --> B{La propiedad esta registrada}
B -- No --> C[El valor malo se guarda tal cual]
C --> D[Cada var que lo use produce un valor invalido]
D --> E[Cada propiedad consumidora cae a unset]
E --> F[Sintomas distintos en cada sitio]
B -- Si --> G[El valor se rechaza en la propia propiedad]
G --> H[La propiedad toma el inicial o el heredado]
H --> I[Todos los consumidores reciben un valor correcto]
style F fill:#f38ba8,color:#11111b
style I fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b

La rama roja es lo que hace tan cara la depuración de un sistema de tokens sin registrar. El valor malo se escribe en un sitio y los síntomas aparecen en otros diez, cada uno con su forma: aquí un color heredado del padre, allá un fondo transparente, más allá una abreviada completa que se evapora. Ninguno de esos diez elementos menciona la causa en su panel de estilos, porque la causa está en un ancestro y en una propiedad que ni siquiera aparece en la lista de declaraciones aplicadas.

La rama verde reduce el problema a un solo punto: la propiedad tiene un valor por defecto correcto y el sistema sigue en pie. El defecto sigue existiendo —la tarjeta se ve con el radio equivocado— pero es un defecto localizado y no una cascada de síntomas.

Los casos límite que sorprenden

initial cambia de significado. Éste es el importante. En una propiedad sin registrar, --x: initial la deja garantizadamente inválida, es decir, la borra, y por eso var(--x, reserva) usa la reserva. En una propiedad registrada, --x: initial la pone a su initial-value, que es un valor perfectamente válido, y por tanto var(--x, reserva) ya no usa la reserva nunca.

@property --acento { syntax: "<color>"; inherits: true; initial-value: black; }

.a { --acento: initial; color: var(--acento, red); }
/* Sin registrar seria rojo. Registrada es negro. */

De ahí salen dos consecuencias prácticas: el interruptor de espacio deja de conmutar, como ya sabías, y todos los fallbacks de var() de esa propiedad se vuelven código muerto. No es un problema —la propiedad siempre tiene valor, que era el objetivo— pero conviene borrarlos para que nadie los lea como si hicieran algo.

Las demás palabras clave globales siguen funcionando. inherit, unset y revert hacen lo que esperas sobre una propiedad registrada, teniendo en cuenta su inherits.

Un var() roto dentro del valor también cae al inicial. Si escribes --radio: calc(var(--base) * 2) y --base no existe, el valor entero es inválido en tiempo de computación y --radio toma su initial-value. Otra vez: el error se contiene.

Los valores tocados por una animación tienen restricciones. Si el valor de una custom property procede de una animación en curso, la especificación lo marca y no permite usarlo en las propiedades que controlan la propia animación —duraciones, retardos— porque produciría una dependencia circular. Es un caso raro, pero si alguna vez ves que un animation-duration: var(--d) no aplica en un elemento cuya --d se está animando, ésta es la razón.

⚠️
El síntoma es un valor razonable, y eso lo hace peor

Cuando una propiedad sin registrar falla, el resultado suele ser feo y llamativo: texto que cambia de color, fondos que desaparecen. Cuando falla una registrada, el resultado es el initial-value, que por definición es un valor sensato que tú mismo elegiste. El fallo se ve bien, solo que no es lo que pedías, y por eso puede sobrevivir meses en producción sin que nadie lo note. Elige valores iniciales que sean seguros pero no invisibles.

Diagnosticarlo

Tres pasos, en orden.

Compara lo declarado con lo calculado. Si el panel de valores calculados muestra exactamente tu initial-value y tú habías declarado otra cosa, la asignación se ha rechazado. Es la señal inequívoca.

Comprueba que la regla @property se registró. Si falta un descriptor obligatorio, la regla se ignora entera y la propiedad funciona como no registrada; entonces el síntoma es el contrario, y verás unset en los consumidores en lugar del inicial. Distinguir los dos casos te dice de golpe si el problema está en el registro o en la asignación.

/* Comprobacion rapida: si esta registrada, el valor calculado esta resuelto. */
getComputedStyle(el).getPropertyValue('--radio');  /* "8px" y no "calc(...)" */

Pon un valor inicial escandaloso mientras depuras. Cambiar temporalmente initial-value a magenta o a 99px convierte un fallo silencioso en uno imposible de pasar por alto, y te dice al instante qué elementos están cayendo al camino de error.

Registrar te quita el tercer estado: la ausencia

Toda la familia de trucos que has visto en el nivel anterior —el interruptor de espacio, las guardas con fallback, los parámetros opcionales— se apoya en un hecho que resulta fácil de pasar por alto: una custom property sin registrar tiene tres estados, no dos. Puede tener un valor bueno, puede tener un valor malo, y puede no existir, que es un estado distinto de los otros dos y perfectamente detectable desde CSS, porque es el único en el que var() usa su fallback. Ese tercer estado es la materia prima de todos los condicionales que la comunidad construyó, y es también lo que permite que un componente distinga “no me han configurado este parámetro” de “me lo han configurado con este valor”. Registrar la propiedad elimina el tercer estado: a partir de ese momento la propiedad siempre tiene un valor válido, porque cuando no lo tiene toma el inicial, y ya no hay forma de preguntar desde CSS si alguien la configuró. Es un intercambio, y conviene hacerlo con los ojos abiertos: ganas garantía —ninguna declaración se va a romper nunca por culpa de esta propiedad— y pierdes expresividad —ya no puedes ramificar según su presencia. La conclusión operativa no es elegir un bando sino elegir por propiedad, y hacerlo según lo que la propiedad significa. Un token de diseño que siempre debe tener un valor sensato —un color de marca, un radio base, una duración— se registra sin dudarlo, porque su ausencia no significa nada útil. Un parámetro opcional cuyo sentido es precisamente “si me lo pasas, cambio de comportamiento” no se registra, o se registra con "*", porque su ausencia es información. Y si un mismo nombre necesita las dos cosas en sitios distintos, no tienes un problema de registro: tienes dos parámetros que se han quedado con el mismo nombre.

⚔️ Rompe el contrato
  1. Asigna un valor inválido a una propiedad registrada y comprueba que cae al inicial.
  2. Repite con inherits: true y verifica que cae al heredado y no al inicial.
  3. Demuestra que --x: initial significa cosas distintas antes y después de registrar.
  4. Rompe un var() anidado dentro del valor de una propiedad registrada y observa el destino.
  5. Distingue en el inspector un fallo de asignación de un fallo de registro.