Skip to content
← All posts

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

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.

Illustration of two type systems: a shape that gets copied as it passes from hand to hand and another that is shared by reference, with a container that may be empty

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.

TL;DR
  • In Swift, structs are copied and classes are shared. Almost your entire data model should be struct; what you solved in TypeScript with spread and disciplined immutability, the compiler guarantees here.
  • An optional is not a value that can be null: it is a distinct type the compiler forces you to unwrap. Enums with associated data replace discriminated unions and are stricter than them.
  • Protocols with extensions and retroactive conformances replace structural interfaces. Codable 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.

In this article:

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.

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 structs, 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:

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 ifs.

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:

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.

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.

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 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:

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:

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.

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.
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.

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 reflexIn Swift
const by default, let only if it mutatesSame: 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 functionsSame: map, filter, reduce, compactMap (which also drops the nils)
async/awaitSame syntax, different model: there are actors and structured cancellation, covered in the concurrency post
Discriminated unionsEnums with associated data, stricter
Structural interfaceNominal protocol, with extensions and default implementations
zodCodable for the shape, validated types for the domain
Spread to copy without mutatingUnnecessary: structs copy themselves
Object.freeze, deep readonlyUnnecessary as long as the tree is made of structs: let freezes all of its properties. A property that holds a class still points at a mutable object
Barrel files, index.tsThey do not exist: the module is the unit of visibility, internal by default

The three habits most worth dropping:

  1. Modeling with classes. structs 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 structs 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: structs 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 nils 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 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.

Keep reading