---
title: "Swift for people who already master TypeScript: the five differences that change how you design"
description: "Swift explained for TypeScript developers: value vs reference semantics, optionals as part of the type system, enums with associated data, protocols with extensions, and Codable instead of zod."
author: Ramón Chancay
date: 2026-09-02
lang: en
tags: [Swift, TypeScript, iOS, React Native, Codable, Types]
canonical: https://www.ramonchancay.me/blog/swift-for-typescript-developers
---

# Swift for people who already master TypeScript: the five differences that change how you design

Swift and TypeScript look similar enough that a senior React Native developer can read a `.swift` file and understand eighty percent of it without help. That eighty percent is the problem: the remaining twenty is not syntax, it is language design decisions that change how you structure code, and your TypeScript reflexes push you in exactly the wrong direction. This post does not go over `let`, `var` or how to declare a function. It covers only the five differences that, in my experience moving from Expo projects to Xcode projects, force you to design differently: value versus reference semantics, optionals as part of the type system, enums with associated data, protocols with extensions, and `Codable` as the replacement for zod.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> TL;DR <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>In Swift, <code>struct</code>s are copied and <code>class</code>es are shared. Almost your entire data model should be <code>struct</code>; what you solved in TypeScript with spread and disciplined immutability, the compiler guarantees here.</li>
<li>An optional is not a value that can be <code>null</code>: it is a distinct type the compiler forces you to unwrap. Enums with associated data replace discriminated unions and are stricter than them.</li>
<li>Protocols with extensions and retroactive conformances replace structural interfaces. <code>Codable</code> covers half of what zod did (the shape of the data, with errors that point at the field); the other half, the domain rules, is written as validated types.</li>
</ul>
</div>
</details>

**In this article:**

- **Memory and types** — [Value or reference](#value-or-reference-the-decision-typescript-never-asked-you-to-make) · [Optionals](#optionals-not-a-null-check-a-type)
- **Modeling** — [Enums with data](#enums-with-associated-data-the-discriminated-union-the-compiler-closes) · [Protocols and extensions](#protocols-and-extensions-the-interface-that-also-implements)
- **Data** — [Codable instead of zod](#codable-the-zod-that-ships-with-the-language) · [What doesn't change](#what-doesnt-change-and-what-you-can-stop-doing)

## Value or reference: the decision TypeScript never asked you to make

In TypeScript every object is a reference. A `const user = {...}` is passed by reference, gets mutated from anywhere that holds it, and immutability is a discipline you impose with `readonly`, with spread or with a library. React lives on that discipline: state changes when the reference changes, which is why you write `setState({...state, name})` instead of `state.name = ...`.

Swift makes you choose for every type. A `struct` has value semantics: when you assign it or pass it to a function, it is copied. A `class` has reference semantics: it is shared, as in TypeScript. What matters is not when the copy physically happens (the standard library collections, such as `Array`, `Dictionary` and `String`, defer it with copy-on-write; a `struct` of your own is copied when the compiler decides), but the guarantee: after copying a value, modifying one does not observably change the other. A `struct` you pass to a function cannot be modified by that function without you seeing it.

```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               // full copy, address included
b.address.city = "Lima"

print(a.address.city)   // "Quito": a never noticed
```

The same code in TypeScript, with nested objects, is a classic bug: `b.address.city = "Lima"` also changes `a`, because `address` is a shared reference. In Swift that does not happen as long as the tree is made of `struct`s, as it is here, where `Address` is one too. The guarantee breaks as soon as a property holds a reference: if `User` had a `var settings: Settings` and `Settings` were a `class`, two copies of `User` would share that object. That is why the rule below is not "use `struct` for the top-level type", it is "use `struct` throughout the whole tree". With that in place you need neither `structuredClone` nor an immutability library.

The practical rule I use: **the entire data model is `struct`**. Users, orders, API responses, screen state. `class` is left for what has identity and its own lifecycle: a network client, an audio player, a database connection, a ViewModel observed by a view. When in doubt, `struct`; the compiler tells you when you really need a reference.

There is one consequence you never had to think about in React Native. To mutate a `struct` you receive as a parameter you have to declare it `inout`, and to mutate a property of a `struct` from a method, the method has to be marked `mutating`. At first it is annoying. Then you realize that every place where a piece of data can change is flagged in the signature, and that you stopped chasing hidden mutations.

## Optionals: not a null check, a type

TypeScript with `strictNullChecks` already forces you to handle `undefined`. Swift goes one step further, and the difference is worth understanding because it changes how you write signatures.

In TypeScript, `string | undefined` is a union: the variable can be either of the two, and the compiler's narrowing lets you use it as a `string` after an `if`. In Swift, `String?` is syntactic sugar for `Optional<String>`, an enum with two cases, `.some(value)` and `.none`. It is not "a string that may be missing", it is a container. To get to the string you have to unwrap it, and the language gives you several ways to do it, each with a different intent:

```swift
func displayName(for user: User?) -> String {
    // guard let: unwrap or exit. The preferred form for preconditions.
    guard let user else { return "Guest" }

    // if let: unwrap inside a local block.
    if let nickname = user.nickname {
        return nickname
    }

    // ?? : default value, same as in TypeScript.
    return user.name ?? "No name"
}
```

`guard let` is the one that changes the style the most. In TypeScript you write `if (!user) return` and move on; in Swift you write `guard let user else { return }` and from that line on `user` is no longer optional, across the whole scope of the function. The result is functions with the preconditions at the top and the happy path without indentation, instead of a pyramid of `if`s.

The second thing that changes is chaining. `user?.address?.city` works as in TypeScript, but the result is `String?`, not `string | undefined`, and you cannot pass it to a function that expects `String` without unwrapping it. That forces a decision at every boundary: does this function accept an optional or demand a value? In my code, domain functions demand values and input functions (parsing, reading from the network, reading from a form) return optionals. The optional is unwrapped once, at the edge, and the rest of the program works with concrete types.

What you must not do, and what everyone coming from JavaScript does in the first week, is the force unwrap: `user!.name`. It compiles, and if the optional is empty the app crashes right there. Reserve `!` for the case where an empty value is a programming bug you want to blow up in development, such as an `IBOutlet` or a bundle resource you know exists. For everything else, unwrap the optional.

## Enums with associated data: the discriminated union the compiler closes

Discriminated unions are TypeScript's most powerful modeling tool, and you probably already use them for loading states:

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

Swift has the same thing, but as a first-class concept: an `enum` whose cases carry data.

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

The difference is not cosmetic. In TypeScript, `switch (state.status)` is exhaustive only if you enable the check with a `never` in the `default`, and the discriminator field is a convention any object can imitate. In Swift, a `switch` over an enum is exhaustive by default: if you add a case, every `switch` in the project stops compiling until you handle it. And associated data can only be extracted through pattern matching, so there is no way to read `value` in the `failed` state.

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

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

Two uses you solved in React Native with strings or loose objects and that in Swift call for an enum:

- **Navigation routes.** An `enum Route { case order(id: String); case profile; case settings(section: Section) }` replaces Expo Router's string paths. The [navigation post](/blog/swiftui-navigation-without-file-based-routing) goes into detail.
- **Reducer actions.** If you use Redux or Zustand with typed actions, an `enum Action` with associated data is the direct translation, and it is what TCA uses as its core.

Enums can also have methods, computed properties and conform to protocols. An `enum Tab: String, CaseIterable` with a `var title: String` and a `var icon: String` replaces the array of configuration objects you used to keep for a tab bar, and the compiler guarantees none is missing.

## Protocols and extensions: the interface that also implements

A TypeScript `interface` is structural: any object with the right shape satisfies it, whether it declares so or not. A Swift `protocol` is nominal: a type satisfies it only if it declares the conformance. That difference looks like a nuisance until you see what it enables.

First, **extensions**. You can add methods and conformances to any type, including system types and third-party library types, without inheriting or wrapping:

```swift
extension Date {
    /// Short-format date for the UI.
    var shortLabel: String {
        formatted(date: .abbreviated, time: .omitted)
    }
}

extension Order: Identifiable {}   // retroactive conformance: it already has `id`
```

In TypeScript, adding a method to `Date` means touching the global prototype or writing a loose function. In Swift it is an extension scoped to the module, and the result reads as if the method had always been there. Retroactive conformance (making a type you do not own satisfy a protocol of yours) is what lets Swift libraries integrate without adapters.

Second, **protocols with default implementations**. A protocol extension can implement methods for every type that conforms to it:

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

extension Repository {
    // Default implementation: searches the full list.
    // Each repository can override it with a direct query.
    func fetch(id: Item.ID) async throws -> Item? {
        try await fetchAll().first { $0.id == id }
    }
}
```

This replaces two patterns you solved in TypeScript with abstract classes or function composition: shared behavior without inheritance, and contracts that ship with their basic implementation. In practice, protocol-oriented design is the dominant style in Swift: you define the contract, provide a default implementation, and the concrete types only write what sets them apart.

The `associatedtype` is the equivalent of a generic on the interface (`Repository<Item>`), with one important difference: a protocol with an `associatedtype` cannot be used directly as the type of a variable without `any` or without generics. At first the compiler will remind you of this with confusing errors. The fix is almost always to write the function as a generic (`func load<R: Repository>(from repo: R)`), or its short form `some Repository` as the parameter type, which is sugar for that generic and lets the caller choose the concrete type. As a return type, `some Repository` means the opposite: the implementer decides the concrete type and the caller only knows it satisfies the protocol. It is the same word pointing in two directions, and it helps to have that clear from the start. It is the roughest spot in Swift for someone coming from TypeScript, and it is worth accepting early instead of fighting it.

## Codable: the zod that ships with the language

In React Native, an API response arrives as `unknown` and has to be validated. zod became the standard because it does two things at once: it validates the shape and produces the type. In Swift, the first half of that job is done by `Codable`, and it ships with the language; the second half, which in zod was `refine`, `transform` and the domain validations, is still your own code, and further down I explain where it goes.

```swift
struct Order: Codable, Identifiable {
    let id: String
    let total: Decimal
    let createdAt: Date
    let items: [OrderItem]
    let note: String?          // optional: if missing from the JSON, it is nil, not an 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)
```

What `decode` does is what `OrderSchema.parse(json)` did: if `total` is not a number, if `id` is missing or if `items` is not an array, it throws an error with the exact path of the field that failed. If everything checks out, the result is an `[Order]` with real types, not an `any`. The difference is that here the schema is the type itself, not a separate definition you have to keep in sync.

Two adjustments that are almost always needed and that you solved in zod with `.transform`:

- **Different names between JSON and Swift.** An `enum CodingKeys: String, CodingKey` inside the `struct` maps `"customer_ref"` to `customerRef` when `convertFromSnakeCase` is not enough.
- **Odd formats.** A date as a timestamp in seconds, a number that arrives as a string, a field that is sometimes an object and sometimes an array. For those you implement `init(from decoder:)` by hand only in that type, and the rest stays automatic.

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

    // The API sends the price as a 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: "Invalid price: \(raw)"
            )
        }
        amount = value
    }
}
```

What `Codable` does not do and zod does: domain validations. That an email has a valid format, that a quantity is positive, that a date is in the future. I do that in a second step, with initializers that return optionals or with types that can only be constructed validated (a `struct Email` with an `init?(_ raw: String)`). The honest equivalence is `schema.parse(json)` ≈ `Decodable` plus that domain validation. The separation is healthy: `Codable` guarantees the shape, and the domain model guarantees the meaning.

```text
HTTP response (Data)
   │
   ▼
JSONDecoder.decode([OrderDTO].self)     ← shape: fields, types, dates
   │
   ├── fails ──► DecodingError with the field path
   │
   ▼
OrderDTO.toDomain()                      ← meaning: totals, valid states
   │
   ├── fails ──► domain error, shown to the user
   │
   ▼
[Order] ready for the UI
```

The same `Codable` works in the other direction: `JSONEncoder` produces the JSON to send, and it also serves to persist to disk or to `UserDefaults`. One type, three uses.

## What doesn't change, and what you can stop doing

A short list of TypeScript reflexes that carry over, and another of those worth letting go.

| TypeScript reflex | In Swift |
|---|---|
| `const` by default, `let` only if it mutates | Same: `let` by default, `var` only if it mutates |
| Generics with constraints (`<T extends X>`) | Same: `<T: X>`, with `where` for extra conditions |
| Closures and higher-order functions | Same: `map`, `filter`, `reduce`, `compactMap` (which also drops the `nil`s) |
| `async`/`await` | Same syntax, different model: there are actors and structured cancellation, covered in the [concurrency post](/blog/swift-concurrency-goodbye-event-loop) |
| Discriminated unions | Enums with associated data, stricter |
| Structural `interface` | Nominal `protocol`, with extensions and default implementations |
| zod | `Codable` for the shape, validated types for the domain |
| Spread to copy without mutating | Unnecessary: `struct`s copy themselves |
| `Object.freeze`, deep `readonly` | Unnecessary as long as the tree is made of `struct`s: `let` freezes all of its properties. A property that holds a `class` still points at a mutable object |
| Barrel files, `index.ts` | They do not exist: the module is the unit of visibility, `internal` by default |

The three habits most worth dropping:

1. **Modeling with classes.** `struct`s with `let` are the normal state; classes are the exception with identity.
2. **Using strings where there is a closed set of values.** Enum, always. The compiler tells you when you forget a case.
3. **Passing optionals inward.** Unwrap the optional at the edge and work with concrete values in the domain.

## Frequently asked questions

### Does Swift have anything like `any` or `unknown`?

`Any` exists, and it is closer to TypeScript's `unknown` than to its `any`: it can hold any value, but you cannot do anything specific with it until you check the type with `as?` or a `switch`. There is no equivalent of TypeScript's `any` that lets you call whatever you want without checking. `any Protocol` (lowercase, as a keyword) is something else: an existential type, "something that satisfies this protocol". In practice neither shows up much, because the entry point for external data is `Data`, and from there you get out with `Codable` into concrete types.

### Aren't large `struct`s slow because they get copied so much?

In general, no. The standard library collections and strings use copy-on-write: the actual copy only happens when one of the two sides mutates. A `struct` with ten fields and an array of a thousand elements is passed as a reference until someone modifies it. If profiling shows expensive copies, then a `class` is worth considering, but it is rare for that to be necessary.

### Is there anything like the utility types `Partial<T>`, `Pick<T>` or `Omit<T>`?

No. Swift has no types derived from other types by transformation. What in TypeScript was `Partial<Order>` for a form is usually, in Swift, an explicit `struct OrderDraft` with optionals, or a separate editing `struct`. It is more code and clearer about which fields may be missing at each stage.

### How do I handle errors? Is there `try`/`catch`?

There is `throws`, `try`, `do`/`catch` and, since Swift 6, typed errors (`throws(NetworkError)`). The difference from JavaScript is that a function that can fail declares it in its signature, and the compiler forces you to write `try` at every call site. The errors you throw are types that conform to `Error`, almost always enums, so the `catch` can pattern match on the specific case.

### Is it worth learning Objective-C?

To read it, a little: you will run into headers, old examples and the occasional crash log with method names in square brackets. To write it, no, unless you maintain a legacy project. Everything new from Apple is exposed in Swift first, and some recent APIs no longer have an Objective-C version.

## Conclusion

The differences between Swift and TypeScript that matter are not in the syntax, they are in the guarantees: `struct`s are copied, so immutability stops being discipline and becomes a rule, as long as the tree is made of values; optionals are a type you have to unwrap, so `nil`s get resolved at the edge; enums with data close the `switch`, so an impossible state does not compile; protocols with extensions replace inheritance and structural interfaces; and `Codable` does in the language the part of zod that validates the shape, leaving the domain part to your types.

If you come from TypeScript, do this in your first project: model the whole domain with `struct` and `enum`, define one protocol per external dependency with its default implementation, and decode the network with `Codable` into DTOs you then convert to domain types. With that covered, the [next post](/blog/swiftui-is-not-react) gets into the part that misleads people coming from React the most: SwiftUI looks enough like React that your reflexes fail you exactly where debugging is hardest.
