---
title: "Swift para quien ya domina TypeScript: las cinco diferencias que cambian cómo diseñas"
description: "Swift explicado para desarrolladores de TypeScript: value vs reference semantics, opcionales como sistema de tipos, enums con datos, protocolos con extensiones y Codable en lugar de zod."
author: Ramón Chancay
date: 2026-09-02
lang: es
tags: [Swift, TypeScript, iOS, React Native, Codable, Tipos]
canonical: https://www.ramonchancay.me/es/blog/swift-para-quien-domina-typescript
---

# Swift para quien ya domina TypeScript: las cinco diferencias que cambian cómo diseñas

Swift y TypeScript se parecen lo suficiente como para que un senior de React Native lea un archivo `.swift` y entienda el ochenta por ciento sin ayuda. Ese ochenta por ciento es el problema: el veinte restante no es sintaxis, son decisiones de diseño del lenguaje que cambian cómo estructuras el código, y los reflejos de TypeScript te llevan justo en la dirección equivocada. Este post no repasa `let`, `var` ni cómo se declara una función. Cubre solo las cinco diferencias que, en mi experiencia pasando de proyectos con Expo a proyectos en Xcode, obligan a diseñar distinto: semántica de valor frente a referencia, opcionales como parte del sistema de tipos, enums con datos asociados, protocolos con extensiones y `Codable` como reemplazo de zod.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>En Swift los <code>struct</code> se copian y las <code>class</code> se comparten. Casi todo tu modelo de datos debería ser <code>struct</code>; lo que en TypeScript resolvías con spread e inmutabilidad disciplinada, aquí lo garantiza el compilador.</li>
<li>Un opcional no es un valor que puede ser <code>null</code>: es un tipo distinto que el compilador te obliga a abrir. Los enums con datos asociados reemplazan a las uniones discriminadas y son más estrictos que ellas.</li>
<li>Los protocolos con extensiones y las conformidades retroactivas sustituyen a las interfaces estructurales. <code>Codable</code> cubre la mitad de lo que hacía zod (la forma de los datos, con errores que señalan el campo); la otra mitad, las reglas de dominio, se escribe en tipos validados.</li>
</ul>
</div>
</details>

**En este artículo:**

- **Memoria y tipos** — [Valor o referencia](#valor-o-referencia-la-decisión-que-typescript-nunca-te-pidió) · [Opcionales](#opcionales-no-es-un-null-check-es-un-tipo)
- **Modelado** — [Enums con datos](#enums-con-datos-asociados-la-unión-discriminada-que-el-compilador-cierra) · [Protocolos y extensiones](#protocolos-y-extensiones-la-interfaz-que-también-implementa)
- **Datos** — [Codable en vez de zod](#codable-el-zod-que-viene-con-el-lenguaje) · [Lo que no cambia](#lo-que-no-cambia-y-lo-que-puedes-dejar-de-hacer)

## Valor o referencia: la decisión que TypeScript nunca te pidió

En TypeScript todo objeto es una referencia. Un `const user = {...}` se pasa por referencia, se muta desde cualquier sitio que lo tenga, y la inmutabilidad es una disciplina que impones con `readonly`, con spread o con una librería. React vive de esa disciplina: el estado cambia cuando cambia la referencia, y por eso `setState({...state, name})` en vez de `state.name = ...`.

Swift te hace elegir en cada tipo. Un `struct` tiene semántica de valor: al asignarlo o pasarlo a una función, se copia. Un `class` tiene semántica de referencia: se comparte, como en TypeScript. Lo importante no es cuándo ocurre físicamente la copia (las colecciones de la librería estándar, como `Array`, `Dictionary` y `String`, la difieren con copy-on-write; un `struct` tuyo se copia cuando el compilador lo decide), sino la garantía: después de copiar un valor, modificar uno no cambia el otro de forma observable. Un `struct` que le pasas a una función no puede ser modificado por esa función sin que tú lo veas.

```swift
struct Address {
    var city: String
}

struct User {
    var name: String
    var address: Address
}

var a = User(name: "Ana", address: Address(city: "Quito"))
var b = a               // copia completa, incluida la dirección
b.address.city = "Lima"

print(a.address.city)   // "Quito": a no se enteró de nada
```

El mismo código en TypeScript, con objetos anidados, es un bug clásico: `b.address.city = "Lima"` cambia también `a`, porque `address` es una referencia compartida. En Swift eso no pasa mientras el árbol sea de `struct`, como aquí, donde `Address` también lo es. La garantía se rompe en cuanto una propiedad guarda una referencia: si `User` tuviera un `var settings: Settings` y `Settings` fuera una `class`, dos copias de `User` compartirían ese objeto. Por eso la regla de abajo no es "usa `struct` para el tipo de arriba", es "usa `struct` en todo el árbol". Con eso no necesitas `structuredClone` ni una librería de inmutabilidad.

La regla práctica que uso: **todo el modelo de datos es `struct`**. Usuarios, pedidos, respuestas de API, estados de pantalla. Las `class` quedan para lo que tiene identidad y ciclo de vida propio: un cliente de red, un reproductor de audio, una conexión a base de datos, un ViewModel observado por una vista. Si dudas, `struct`; el compilador te avisa cuando de verdad necesitas una referencia.

Hay una consecuencia que en React Native no tenías que pensar. Para mutar un `struct` que recibes como parámetro tienes que declararlo `inout`, y para mutar una propiedad de un `struct` desde un método, el método tiene que marcarse `mutating`. Al principio molesta. Después te das cuenta de que cada sitio donde un dato puede cambiar está señalado en la firma, y que dejaste de perseguir mutaciones escondidas.

## Opcionales: no es un null-check, es un tipo

TypeScript con `strictNullChecks` ya te obliga a manejar `undefined`. Swift va un paso más allá y conviene entender la diferencia, porque cambia cómo escribes las firmas.

En TypeScript, `string | undefined` es una unión: la variable puede ser cualquiera de los dos y el narrowing del compilador te deja usarla como `string` después de un `if`. En Swift, `String?` es azúcar sintáctico para `Optional<String>`, un enum con dos casos, `.some(valor)` y `.none`. No es "un string que puede faltar", es un contenedor. Para llegar al string tienes que abrirlo, y el lenguaje te da varias formas de hacerlo, cada una con una intención distinta:

```swift
func displayName(for user: User?) -> String {
    // guard let: abre o sale. La forma preferida para precondiciones.
    guard let user else { return "Invitado" }

    // if let: abre en un bloque local.
    if let nickname = user.nickname {
        return nickname
    }

    // ?? : valor por defecto, igual que en TypeScript.
    return user.name ?? "Sin nombre"
}
```

`guard let` es el que más cambia el estilo. En TypeScript escribes `if (!user) return` y sigues; en Swift escribes `guard let user else { return }` y a partir de esa línea `user` ya no es opcional, en todo el ámbito de la función. El resultado son funciones con las precondiciones arriba y el camino feliz sin indentación, en vez de una pirámide de `if`.

Lo segundo que cambia es el encadenamiento. `user?.address?.city` funciona como en TypeScript, pero el resultado es `String?`, no `string | undefined`, y no puedes pasarlo a una función que espera `String` sin abrirlo. Eso obliga a decidir en cada frontera: ¿esta función acepta un opcional o exige un valor? En mi código, las funciones de dominio exigen valores y las de entrada (parsear, leer de red, leer de un formulario) devuelven opcionales. El opcional se abre una vez, en el borde, y el resto del programa trabaja con tipos concretos.

Lo que no debes hacer, y que todo el que viene de JavaScript hace la primera semana, es el force unwrap: `user!.name`. Compila, y si el opcional está vacío la app se cierra en ese punto. Reserva el `!` para el caso en que un valor vacío sea un bug de programación que quieres que explote en desarrollo, como un `IBOutlet` o un recurso del bundle que sabes que existe. Para todo lo demás, abre el opcional.

## Enums con datos asociados: la unión discriminada que el compilador cierra

Las uniones discriminadas son la herramienta de modelado más potente de TypeScript, y probablemente ya las usas para estados de carga:

```ts
type Loadable<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "loaded"; value: T }
  | { status: "failed"; error: Error };
```

Swift tiene lo mismo, pero como concepto de primera clase: un `enum` cuyos casos llevan datos.

```swift
enum Loadable<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(Error)
}
```

La diferencia no es cosmética. En TypeScript, `switch (state.status)` es exhaustivo solo si activas la comprobación con un `never` en el `default`, y el campo discriminador es una convención que cualquier objeto puede imitar. En Swift el `switch` sobre un enum es exhaustivo por defecto: si agregas un caso, cada `switch` del proyecto deja de compilar hasta que lo manejes. Y los datos asociados solo se pueden extraer haciendo pattern matching, así que no existe la posibilidad de leer `value` en el estado `failed`.

```swift
struct OrdersView: View {
    let state: Loadable<[Order]>

    var body: some View {
        switch state {
        case .idle:
            Text("Sin cargar")
        case .loading:
            ProgressView()
        case .loaded(let orders):
            List(orders) { order in Text(order.title) }
        case .failed(let error):
            Text(error.localizedDescription)
        }
    }
}
```

Dos usos que en React Native resolvías con strings o con objetos sueltos y que en Swift piden un enum:

- **Rutas de navegación.** Un `enum Route { case order(id: String); case profile; case settings(section: Section) }` reemplaza a los paths como strings de Expo Router. El post de navegación entra en detalle.
- **Acciones de un reducer.** Si usas Redux o Zustand con acciones tipadas, `enum Action` con datos asociados es la traducción directa, y es lo que TCA usa como núcleo.

Los enums también pueden tener métodos, propiedades calculadas y conformar protocolos. Un `enum Tab: String, CaseIterable` con un `var title: String` y un `var icon: String` reemplaza al array de objetos de configuración que solías tener para una tab bar, y el compilador te garantiza que no falta ninguna.

## Protocolos y extensiones: la interfaz que también implementa

Una `interface` de TypeScript es estructural: cualquier objeto con la forma correcta la cumple, lo declare o no. Un `protocol` de Swift es nominal: un tipo lo cumple solo si declara la conformidad. Esa diferencia parece una molestia hasta que ves lo que habilita.

Primero, las **extensiones**. Puedes agregar métodos y conformidades a cualquier tipo, incluidos los del sistema y los de librerías de terceros, sin heredar ni envolver:

```swift
extension Date {
    /// Fecha en formato corto para la UI.
    var shortLabel: String {
        formatted(date: .abbreviated, time: .omitted)
    }
}

extension Order: Identifiable {}   // conformidad retroactiva: ya tiene `id`
```

En TypeScript, agregar un método a `Date` significa tocar el prototipo global o escribir una función suelta. En Swift es una extensión con ámbito de módulo, y el resultado se lee como si el método hubiera estado siempre ahí. La conformidad retroactiva (hacer que un tipo ajeno cumpla un protocolo tuyo) es lo que hace que las librerías de Swift se integren sin adaptadores.

Segundo, los **protocolos con implementación por defecto**. Una extensión de un protocolo puede implementar métodos para todos los tipos que lo cumplan:

```swift
protocol Repository {
    associatedtype Item: Identifiable
    func fetchAll() async throws -> [Item]
    func fetch(id: Item.ID) async throws -> Item?
}

extension Repository {
    // Implementación por defecto: busca en la lista completa.
    // Cada repositorio puede sobrescribirla con una consulta directa.
    func fetch(id: Item.ID) async throws -> Item? {
        try await fetchAll().first { $0.id == id }
    }
}
```

Esto reemplaza a dos patrones que en TypeScript resolvías con clases abstractas o con composición de funciones: comportamiento compartido sin herencia, y contratos que traen su implementación básica. En la práctica, el diseño orientado a protocolos es el estilo dominante en Swift: defines el contrato, das una implementación por defecto y los tipos concretos solo escriben lo que los distingue.

El `associatedtype` es el equivalente de un genérico en la interfaz (`Repository<Item>`), con una diferencia importante: un protocolo con `associatedtype` no se puede usar directamente como tipo de una variable sin `any` o sin genéricos. Al principio el compilador te lo va a recordar con errores confusos. La solución casi siempre es escribir la función genérica (`func load<R: Repository>(from repo: R)`), o su forma corta `some Repository` como tipo del parámetro, que es azúcar para ese genérico y deja que quien llama elija el tipo concreto. Como tipo de retorno, `some Repository` significa lo contrario: el tipo concreto lo decide quien implementa y el llamador solo sabe que cumple el protocolo. Es la misma palabra con dos direcciones, y conviene tenerlo claro desde el principio. Es el punto más áspero de Swift para alguien que viene de TypeScript, y vale la pena aceptarlo pronto en vez de pelearlo.

## Codable: el zod que viene con el lenguaje

En React Native, la respuesta de una API llega como `unknown` y hay que validarla. zod se convirtió en el estándar porque hace dos cosas a la vez: valida la forma y produce el tipo. En Swift, la primera mitad de ese trabajo la hace `Codable`, y viene con el lenguaje; la segunda, que en zod eran `refine`, `transform` y las validaciones de dominio, sigue siendo código tuyo, y más abajo explico dónde va.

```swift
struct Order: Codable, Identifiable {
    let id: String
    let total: Decimal
    let createdAt: Date
    let items: [OrderItem]
    let note: String?          // opcional: si falta en el JSON, es nil, no error
}

struct OrderItem: Codable {
    let sku: String
    let quantity: Int
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase   // created_at -> createdAt
decoder.dateDecodingStrategy = .iso8601

let orders = try decoder.decode([Order].self, from: data)
```

Lo que hace `decode` es lo que hacía `OrderSchema.parse(json)`: si `total` no es un número, si falta `id` o si `items` no es un array, lanza un error con la ruta exacta del campo que falló. Si todo cumple, el resultado es un `[Order]` con tipos reales, no un `any`. La diferencia es que aquí el esquema es el tipo mismo, no una definición aparte que hay que mantener sincronizada.

Dos ajustes que casi siempre hacen falta y que en zod resolvías con `.transform`:

- **Nombres distintos entre JSON y Swift.** Un `enum CodingKeys: String, CodingKey` dentro del `struct` mapea `"customer_ref"` a `customerRef` cuando `convertFromSnakeCase` no alcanza.
- **Formatos raros.** Una fecha como timestamp en segundos, un número que llega como string, un campo que a veces es objeto y a veces array. Para eso implementas `init(from decoder:)` a mano solo en ese tipo y el resto sigue siendo automático.

```swift
struct Price: Decodable {
    let amount: Decimal

    // La API manda el precio como string: "12.50".
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let raw = try container.decode(String.self)
        guard let value = Decimal(string: raw) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Precio inválido: \(raw)"
            )
        }
        amount = value
    }
}
```

Lo que `Codable` no hace y zod sí: validaciones de dominio. Que un email tenga formato válido, que una cantidad sea positiva, que una fecha esté en el futuro. Eso lo hago en un segundo paso, con inicializadores que devuelven opcionales o con tipos que solo se pueden construir validados (`struct Email` con un `init?(_ raw: String)`). La equivalencia honesta es `schema.parse(json)` ≈ `Decodable` más esa validación de dominio. La separación es sana: `Codable` garantiza la forma, y el modelo de dominio garantiza el significado.

```text
Respuesta HTTP (Data)
   │
   ▼
JSONDecoder.decode([OrderDTO].self)     ← forma: campos, tipos, fechas
   │
   ├── falla ──► DecodingError con la ruta del campo
   │
   ▼
OrderDTO.toDomain()                      ← significado: totales, estados válidos
   │
   ├── falla ──► error de dominio, se muestra al usuario
   │
   ▼
[Order] listo para la UI
```

El mismo `Codable` funciona hacia el otro lado: `JSONEncoder` produce el JSON para enviar, y también sirve para persistir en disco o en `UserDefaults`. Un tipo, tres usos.

## Lo que no cambia, y lo que puedes dejar de hacer

Una lista corta de reflejos de TypeScript que se conservan y otra de los que conviene soltar.

| Reflejo de TypeScript | En Swift |
|---|---|
| `const` por defecto, `let` solo si muta | Igual: `let` por defecto, `var` solo si muta |
| Genéricos con restricciones (`<T extends X>`) | Igual: `<T: X>`, con `where` para condiciones extra |
| Closures y funciones de orden superior | Igual: `map`, `filter`, `reduce`, `compactMap` (que además quita los `nil`) |
| `async`/`await` | Igual en sintaxis, distinto en modelo: hay actores y cancelación estructurada, tema del post de concurrencia |
| Uniones discriminadas | Enums con datos asociados, más estrictos |
| `interface` estructural | `protocol` nominal, con extensiones e implementaciones por defecto |
| zod | `Codable` para la forma, tipos validados para el dominio |
| Spread para copiar sin mutar | Innecesario: los `struct` se copian solos |
| `Object.freeze`, `readonly` profundo | Innecesario mientras el árbol sea de `struct`: `let` congela todas sus propiedades. Una propiedad que guarda una `class` sigue apuntando a un objeto mutable |
| Barrel files, `index.ts` | No existen: el módulo es la unidad de visibilidad, `internal` por defecto |

Los tres hábitos que más conviene dejar:

1. **Modelar con clases.** Los `struct` con `let` son el estado normal; las clases son la excepción con identidad.
2. **Usar strings donde hay un conjunto cerrado de valores.** Enum, siempre. El compilador te avisa cuando olvidas un caso.
3. **Pasar opcionales hacia adentro.** Abre el opcional en el borde y trabaja con valores concretos en el dominio.

## Preguntas frecuentes

### ¿Swift tiene algo parecido a `any` o a `unknown`?

`Any` existe, y se parece más al `unknown` de TypeScript que a su `any`: puede guardar cualquier valor, pero no puedes hacer nada específico con él hasta comprobar el tipo con `as?` o un `switch`. No hay un equivalente del `any` de TypeScript que deje llamar a lo que sea sin comprobar. `any Protocol` (con minúscula, como palabra clave) es otra cosa: un tipo existencial, "algo que cumple este protocolo". En la práctica ninguno de los dos aparece mucho, porque el punto de entrada de datos externos es `Data`, y de ahí sales con `Codable` a tipos concretos.

### ¿Los `struct` grandes no son lentos por copiarse tanto?

En general no. Las colecciones y los strings de la librería estándar usan copy-on-write: la copia real solo ocurre cuando una de las dos partes muta. Un `struct` con diez campos y un array de mil elementos se pasa como referencia hasta que alguien lo modifica. Si un perfilado muestra copias caras, ahí sí se considera una `class`, pero es raro que haga falta.

### ¿Hay algo como los tipos utilitarios `Partial<T>`, `Pick<T>` u `Omit<T>`?

No. Swift no tiene tipos derivados de otros tipos por transformación. Lo que en TypeScript era `Partial<Order>` para un formulario, en Swift suele ser un `struct OrderDraft` explícito con opcionales, o un `struct` de edición separado. Es más código y más claro sobre qué campos pueden faltar en cada etapa.

### ¿Cómo manejo errores? ¿Hay `try`/`catch`?

Hay `throws`, `try`, `do`/`catch` y, desde Swift 6, errores tipados (`throws(NetworkError)`). La diferencia con JavaScript es que una función que puede fallar lo declara en la firma, y el compilador te obliga a escribir `try` en cada llamada. Los errores que lanzas son tipos que conforman `Error`, casi siempre enums, con lo cual el `catch` puede hacer pattern matching sobre el caso concreto.

### ¿Vale la pena aprender Objective-C?

Para leerlo, un poco: vas a encontrar headers, ejemplos antiguos y algún crash log con nombres de métodos entre corchetes. Para escribirlo, no, salvo que mantengas un proyecto heredado. Todo lo nuevo de Apple se expone en Swift primero y algunas APIs recientes ya no tienen versión en Objective-C.

## Conclusión

Las diferencias entre Swift y TypeScript que importan no están en la sintaxis, están en las garantías: los `struct` se copian y por eso la inmutabilidad deja de ser disciplina y pasa a ser regla, mientras el árbol sea de valores; los opcionales son un tipo que hay que abrir y por eso los `nil` se resuelven en el borde; los enums con datos cierran los `switch` y por eso un estado imposible no compila; los protocolos con extensiones reemplazan a la herencia y a las interfaces estructurales; y `Codable` hace en el lenguaje la parte de zod que valida la forma, y deja la de dominio a tus tipos.

Si vienes de TypeScript, haz esto en tu primer proyecto: modela todo el dominio con `struct` y `enum`, define un protocolo por cada dependencia externa con su implementación por defecto, y decodifica la red con `Codable` a DTOs que después conviertes a tipos de dominio. Con eso cubierto, el [siguiente post](/es/blog/swiftui-no-es-react) entra en la parte que más engaña a quien viene de React: SwiftUI se parece a React lo suficiente como para que tus reflejos te fallen justo donde más cuesta depurar.
