wandres.dev
PROPERTY WRAPPERS · comportamiento reutilizable

projectedValue y el dólar: la segunda cara del wrapper

Una propiedad envuelta expone hasta tres nombres: el valor, el almacenamiento y la proyección. Qué es `projectedValue`, cómo el compilador sintetiza el prefijo con dólar, y por qué ese signo que ves por todo SwiftUI no pertenece a SwiftUI.

⏱ 17 min

Un wrapper que solo sepa devolver el valor envuelto es un filtro y poco más. La pieza que lo convierte en un mecanismo de diseño es projectedValue: una segunda propiedad, de tipo libre, que el wrapper puede exponer junto al valor y a la que se accede con el nombre precedido de un signo de dólar. Con ella, una sola declaración pasa a ofrecer dos superficies distintas —el valor para quien solo quiere leerlo o escribirlo, y algo más rico para quien necesita operar sobre el acceso mismo— sin que el tipo contenedor tenga que declarar nada extra. Es, además, la respuesta a una pregunta que todo el mundo se hace al empezar con SwiftUI y casi nadie llega a responderse: de dónde sale el dólar.

🎯 Al terminar esta lección sabrás
  • Distinguir los tres nombres que genera una propiedad envuelta y qué devuelve cada uno.
  • Declarar projectedValue y elegir con criterio qué tipo conviene proyectar.
  • Explicar el prefijo con dólar de SwiftUI como consecuencia directa del mecanismo del lenguaje.
  • Usar init(projectedValue:) para reconstruir un wrapper a partir de su proyección.

Una propiedad, tres nombres

Cuando aplicas un wrapper, el compilador pone a tu disposición hasta tres identificadores distintos para una sola declaración:

@propertyWrapper
struct Acotado {
    private var valor: Int
    private let rango: ClosedRange<Int>
    private(set) var seRecorto = false

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

    var wrappedValue: Int {
        get { valor }
        set {
            seRecorto = !rango.contains(newValue)
            valor = min(max(newValue, rango.lowerBound), rango.upperBound)
        }
    }

    var projectedValue: Bool { seRecorto }
}

struct Ajustes {
    @Acotado(0...10) var volumen = 5
}

var a = Ajustes()
a.volumen = 99
print(a.volumen)     // 10   -> wrappedValue
print(a.$volumen)    // true -> projectedValue

El reparto es limpio: volumen es el valor envuelto, $volumen es la proyección, y _volumen —accesible solo dentro del tipo— es la instancia del wrapper completa. La visibilidad tampoco es simétrica: el almacenamiento con guion bajo nace private, mientras que la proyección adopta el mismo nivel de acceso que la propiedad original, de modo que forma parte de tu API pública en cuanto la declares. Elegir qué proyectar es, por tanto, una decisión de diseño de interfaz, no un detalle interno.

Proyectar lo que haga falta

projectedValue no tiene ninguna restricción de tipo. Puede ser un dato auxiliar, como el Bool anterior; puede ser un objeto rico; puede ser el propio wrapper. Ese último caso, projectedValue devolviendo self, es más útil de lo que parece: entrega al llamante toda la maquinaria mientras el nombre sin adornos sigue entregando solo el valor.

@propertyWrapper
struct Validado {
    private var valor: String
    private let regla: (String) -> Bool

    init(wrappedValue: String, _ regla: @escaping (String) -> Bool) {
        self.valor = wrappedValue
        self.regla = regla
    }

    var wrappedValue: String {
        get { valor }
        set { valor = newValue }
    }

    var projectedValue: Resultado {
        Resultado(esValido: regla(valor), valor: valor)
    }

    struct Resultado {
        let esValido: Bool
        let valor: String
    }
}

struct Registro {
    @Validado({ $0.contains("@") }) var correo = ""
}

var r = Registro()
r.correo = "hola"
print(r.$correo.esValido)   // false

El patrón que emerge es siempre el mismo: el valor responde a la pregunta cuánto vale, y la proyección responde a preguntas sobre el valor —si es válido, si cambió, quién puede modificarlo, cómo observarlo. Dos preguntas de naturaleza distinta que en un tipo normal exigirían dos propiedades declaradas a mano y mantenidas en sincronía.

flowchart TB
decl[Una sola declaracion con atributo] --> tres[El compilador sintetiza tres nombres]
tres --> a[nombre devuelve wrappedValue]
tres --> b[dolar nombre devuelve projectedValue]
tres --> c[guion bajo nombre es la instancia del wrapper y es privada]
a --> uso1[Para quien solo quiere el valor]
b --> uso2[Para quien opera sobre el acceso al valor]
c --> uso3[Para el codigo interno del tipo]
style decl fill:#cba6f7,color:#11111b
style tres fill:#89b4fa,color:#11111b
style b fill:#a6e3a1,color:#11111b

De dónde sale el dólar de SwiftUI

Con esto ya puedes desmontar la construcción más repetida de SwiftUI. Cuando escribes una vista con estado y pasas el nombre con dólar a un campo de texto, no está ocurriendo nada específico del framework:

struct Formulario: View {
    @State private var texto = ""

    var body: some View {
        VStack {
            TextField("Nombre", text: $texto)
            Text("Has escrito: \(texto)")
        }
    }
}

texto es un String porque State declara wrappedValue de tipo Valor. Y $texto es un Binding de String porque State declara projectedValue de tipo Binding, ni más ni menos. TextField pide un Binding porque necesita escribir en el estado, no solo leerlo, y un String copiado no le serviría de nada. El dólar no es sintaxis de SwiftUI: es la sintaxis del lenguaje para pedirle a un wrapper su segunda cara, y SwiftUI se limita a haber elegido bien qué poner en esa cara.

El mismo mecanismo explica el resto de la familia sin necesidad de reglas nuevas. En Combine, @Published proyecta un publicador, y por eso $modelo.contador es algo a lo que puedes suscribirte. @FocusState proyecta un enlace al foco. @Bindable proyecta enlaces hacia las propiedades de un objeto observable. Tres frameworks, cero sintaxis adicional: solo tipos distintos en projectedValue.

Volver a envolver la proyección

Falta una simetría. Si un wrapper puede construirse desde un valor con init(wrappedValue:), también puede construirse desde una proyección con init(projectedValue:). Eso importa desde que Swift permite aplicar wrappers a los parámetros de una función: quien llama puede pasar directamente el nombre con dólar y el compilador reconstruye el wrapper del otro lado.

struct Contador: View {
    @Binding var valor: Int
    var body: some View { Stepper("Valor: \(valor)", value: $valor) }
}

struct Padre: View {
    @State private var n = 0
    // La proyeccion de n es un Binding y Contador la recibe tal cual
    var body: some View { Contador(valor: $n) }
}

Ese es el punto exacto donde el estado deja de estar encerrado en una vista y empieza a circular por el árbol: la proyección es transportable, el valor no. Un String pasado a un hijo es una copia muerta; un Binding de String es una copia del permiso de escritura sobre la ubicación original.

Reificar la ubicación, no el valor

La idea profunda detrás de projectedValue no es que un wrapper pueda devolver dos cosas, sino qué clase de segunda cosa devuelve. Un valor contesta a la pregunta de cuál es el contenido; una proyección como Binding contesta a algo mucho más difícil de expresar en un lenguaje de valores: dónde vive ese contenido y qué puedo hacerle. En jerga de compiladores, un Binding es un l-value convertido en dato de primera clase, una pareja de clausuras de lectura y escritura empaquetada en un tipo que puedes guardar en un array, pasar a una función o capturar en una clausura. Lenguajes con referencias baratas —C con sus punteros, C# con ref— nunca necesitaron nada así, porque la ubicación siempre estuvo disponible. Swift eligió lo contrario: semántica de valor por todas partes, inout limitado a la duración de una llamada, ninguna manera de guardarse una referencia a la propiedad de otro. Esa decisión es la que hace que el código sea razonable y a la vez la que dejaba a SwiftUI sin forma de expresar el flujo bidireccional de datos, donde un campo de texto necesita escribir en el estado de una vista que se recrea constantemente. projectedValue es la puerta que resuelve la tensión sin abrir agujeros: la ubicación se puede transportar, pero solo si el wrapper decide fabricar y entregar explícitamente ese permiso, con el tipo y las garantías que él elija. La consecuencia práctica merece una frase entera: cuando escribes un dólar en SwiftUI estás pidiendo una capacidad, no un dato, y el hecho de que la sintaxis lo distinga a golpe de vista es precisamente lo que impide confundir leer un valor con poder cambiarlo desde otro sitio.

📝
Lo esencial de la proyección

projectedValue es opcional; si la declaras, el compilador expone $nombre con el mismo nivel de acceso que la propiedad. Puede tener cualquier tipo, incluido el propio wrapper. El dólar de SwiftUI no pertenece a SwiftUI: State proyecta un Binding, @Published proyecta un publicador, y con init(projectedValue:) la proyección puede volver a envolverse.

⚔️ Diseña la segunda cara
  1. Añade a tu wrapper Recortado una proyección que informe de cuántos caracteres se eliminaron en la última escritura.
  2. Escribe un wrapper Historial que guarde los últimos valores asignados y los proyecte como un array.
  3. Implementa un MiBinding propio con dos clausuras de lectura y escritura, y con projectedValue devolviendo self; úsalo para modificar una variable desde otra función.
  4. Localiza en la documentación el tipo exacto que proyectan State, Published y FocusState, y justifica en cada caso por qué ese tipo y no otro.
  5. Explica por qué pasar un String a una subvista no permite el flujo bidireccional y un Binding de String sí, apoyándote en la semántica de valor de Swift.