wandres.dev
PROPERTY WRAPPERS · comportamiento reutilizable

Escribir el tuyo: validar, persistir, registrar

Tres wrappers reales escritos de principio a fin: uno que valida y normaliza, uno que persiste en `UserDefaults` con `nonmutating set`, y uno que registra cada cambio. Con las decisiones de diseño que separan un wrapper útil de una trampa.

⏱ 18 min

Entender la transformación del compilador es una cosa; decidir qué merece envolverse es otra bastante más difícil. Un property wrapper es un lugar privilegiado: se ejecuta en cada lectura y en cada escritura de una propiedad, invisible desde el código que la usa. Esa invisibilidad es su virtud cuando el comportamiento es una invariante del valor, y su peor defecto cuando esconde trabajo caro o efectos que el lector no espera. En esta lección escribiremos tres wrappers completos —validación, persistencia y registro— y, en cada uno, la pregunta de fondo no será cómo se escribe, sino qué clase de comportamiento tiene derecho a volverse invisible.

🎯 Al terminar esta lección sabrás
  • Escribir un wrapper genérico con restricciones de protocolo sobre el valor envuelto.
  • Persistir en UserDefaults y justificar el uso de nonmutating set.
  • Registrar los cambios de una propiedad y reconocer el límite de no ver la instancia contenedora.
  • Distinguir el comportamiento que conviene ocultar del que conviene dejar visible.

Un wrapper que valida

El caso más sano es el que mantiene una invariante del propio valor: normalizar, acotar, recortar. Nada de estado externo, nada de efectos. Generalizando el ejemplo del nivel anterior con Comparable, el wrapper sirve para cualquier tipo ordenable:

@propertyWrapper
struct Acotado<Valor: Comparable> {
    private var valor: Valor
    private let rango: ClosedRange<Valor>

    init(wrappedValue: Valor, _ rango: ClosedRange<Valor>) {
        self.rango = rango
        self.valor = min(max(wrappedValue, rango.lowerBound), rango.upperBound)
    }

    var wrappedValue: Valor {
        get { valor }
        set { valor = min(max(newValue, rango.lowerBound), rango.upperBound) }
    }
}

La variante de saneado de texto es igual de barata y bastante más frecuente en la práctica, porque elimina de golpe una familia entera de bugs de espacios en blanco:

@propertyWrapper
struct Recortado {
    private var valor: String = ""

    init(wrappedValue: String) { self.wrappedValue = wrappedValue }

    var wrappedValue: String {
        get { valor }
        set { valor = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }
}

struct Formulario {
    @Recortado var nombre = ""
    @Acotado(1...120) var edad = 18
}

La propiedad de estos dos wrappers que los hace defendibles es que son puros y locales: el resultado depende solo del valor entrante, no tocan nada fuera y el coste de leerlos es el de leer un campo. Quien escriba formulario.nombre no necesita saber que hay un intermediario, porque el intermediario no hace nada que pueda sorprenderle.

Un wrapper que persiste

El segundo caso es el que más se escribe en el mundo real y el que introduce la decisión de diseño interesante. Queremos que una propiedad viva en UserDefaults en lugar de en memoria:

@propertyWrapper
struct Preferencia<Valor> {
    let clave: String
    let porDefecto: Valor
    let almacen: UserDefaults

    init(wrappedValue: Valor, _ clave: String, almacen: UserDefaults = .standard) {
        self.porDefecto = wrappedValue
        self.clave = clave
        self.almacen = almacen
    }

    var wrappedValue: Valor {
        get { almacen.object(forKey: clave) as? Valor ?? porDefecto }
        nonmutating set { almacen.set(newValue, forKey: clave) }
    }
}

struct Configuracion {
    @Preferencia("modoOscuro") var modoOscuro = false
    @Preferencia("nombreUsuario") var nombreUsuario = "invitado"
}

Observa que el wrapper no guarda el valor: guarda la clave, el valor por defecto y una referencia al almacén. El estado real está fuera del programa, en el disco. Y de ahí sale la pieza clave, nonmutating set. Escribir en la propiedad no modifica ni un byte del wrapper ni del struct que lo contiene, así que declarar el set como no mutante le dice la verdad al compilador y permite asignar a través de una constante:

let config = Configuracion()
config.modoOscuro = true      // legal: el set es nonmutating

Si omitieras nonmutating, Swift consideraría que asignar muta el struct contenedor, exigiría var y propagaría mutating por toda la cadena de llamadas. Es un ejemplo perfecto de cómo la anotación de mutación en Swift no describe la sintaxis sino la semántica: aquí no hay mutación real, y decirlo abre posibilidades.

💡
El coste escondido de la persistencia

Un getter que toca UserDefaults parece gratis y no lo es: hace una búsqueda por clave y un puente dinámico entre tipos. Escrito así, un bucle que lea la propiedad diez mil veces hará diez mil consultas. Si esa propiedad se lee en un camino caliente, cachea el valor en el wrapper y sincroniza en el set, o simplemente lee una vez y guarda el resultado en una variable local.

Un wrapper que registra

El tercer caso vigila los cambios. Es el más instructivo porque es donde el mecanismo empieza a rozar su techo:

@propertyWrapper
struct Registrado<Valor> {
    private var valor: Valor
    private let etiqueta: String

    init(wrappedValue: Valor, _ etiqueta: String) {
        self.valor = wrappedValue
        self.etiqueta = etiqueta
    }

    var wrappedValue: Valor {
        get { valor }
        set {
            print("[\(etiqueta)] \(valor) pasa a \(newValue)")
            valor = newValue
        }
    }
}

struct Sesion {
    @Registrado("token") var token = ""
}

Funciona, y a la vez muestra la carencia estructural del mecanismo: Registrado sabe su etiqueta y su valor, pero no sabe en qué instancia vive. No puede consultar otra propiedad del contenedor, no puede llamar a un método suyo, no puede notificarle. Por eso todo wrapper que quiera avisar de un cambio necesita que se le inyecte a mano el destinatario del aviso, con una clausura o una referencia, y por eso el paso siguiente —que un cambio de propiedad despierte al objeto entero— no se resolvió con wrappers sino con macros. Existe un mecanismo no oficial y con nombre subrayado para acceder a la instancia contenedora desde el wrapper; que nunca se haya estabilizado es la señal más clara de que ese camino no era el bueno.

flowchart LR
esc[Escritura en la propiedad] --> set[Setter de wrappedValue]
set --> val[Validar o normalizar]
set --> per[Escribir en almacen externo]
set --> log[Registrar el cambio]
val --> mem[Estado dentro del wrapper]
per --> disco[Estado fuera del programa]
log --> consola[Efecto observable]
style esc fill:#cba6f7,color:#11111b
style set fill:#89b4fa,color:#11111b
style disco fill:#f9e2af,color:#11111b
style consola fill:#f38ba8,color:#11111b
La invisibilidad es el poder y el peligro

Un property wrapper compra brevedad con opacidad, y ese intercambio no es neutro. En el punto de uso, usuario.nombre = texto se lee exactamente igual tanto si detrás hay un campo desnudo como si hay un recorte de espacios, una escritura en disco, una consulta de red o un candado. El código deja de decir lo que hace. Existe un criterio bastante fiable para saber cuándo el trato sale a cuenta: oculta el comportamiento cuando sea una invariante que el lector daría por supuesta de todos modos, y hazlo explícito cuando sea un efecto del que querría enterarse. Que una edad esté acotada entre uno y ciento veinte es una propiedad del concepto edad; nadie se sorprende, y la invisibilidad ayuda. Que leer una propiedad golpee el disco, tome un candado o dispare una petición no es una propiedad del concepto: es un efecto, y esconderlo tras una asignación inocente convierte el rendimiento y la corrección en algo indescifrable desde el punto de uso. La misma tensión aparece en todos los lenguajes que permiten interceptar el acceso a un campo —los descriptores de Python, las propiedades de C#— y la lección acumulada es siempre igual: cuanto más caro, más asíncrono o más lejano sea el efecto, menos derecho tiene a disfrazarse de asignación. El nombre del wrapper es tu última defensa. Recortado promete exactamente lo que hace; Preferencia avisa de que hay un almacén detrás; un wrapper llamado Inteligente no promete nada y esconde todo.

⚔️ Tres wrappers y una decisión
  1. Escribe NoVacio, un wrapper para String que sustituya por un valor por defecto cualquier texto que quede vacío tras recortar espacios.
  2. Extiende Preferencia para que acepte cualquier Valor que conforme a Codable, serializando a Data en el almacén.
  3. Añade a Preferencia una caché en memoria y explica qué invariante nueva debes mantener y qué pasa si otro proceso escribe la misma clave.
  4. Convierte Registrado para que acepte una clausura de notificación en lugar de imprimir, y razona por qué el wrapper no puede obtener esa clausura por sí solo.
  5. Elige dos propiedades de un proyecto tuyo y decide, con el criterio de invariante frente a efecto, cuál merece un wrapper y cuál no.