Swift for the Web

When Sean and I set out to build Studioworks, we made the decision to build with Swift. It was an unconventional choice that we knew would come with some risks, but we cut our teeth building web apps with PHP before it got good, so a little bit of trail blazing didn’t bother us. Still, we’ve gotten a lot of questions about why we chose to build with Swift, and I wanted to write it down from my perspective.

Prevent errors early

One of our highest priorities at Studioworks is that our software must be as safe and trustworthy as we can make it. That meant choosing a language that would help us avoid mistakes and catch errors well before they ever make it to production.

While Sean and I have extensive experience with Python, we ruled it out because we came to view static type checking as a necessary feature of safe programming languages. The optional type-hinting approach used in modern Python is great for making legacy projects safer, but we wanted to do better than that.

The two languages we seriously considered are Swift and Rust. Our decision came down to both of us having a few years of experience with Swift, and none with Rust. I’d like to talk about some of the Swift features that helped it check our safety box.

The type primitives in Swift are structs, enums, classes and actors. Structs and enums are value types, which means that they are always passed by value. Classes and actors are reference types, and you can guess what that means.

Most scalar types are implemented as structs. Strings, integers, booleans, arrays, and dictionaries are all structs under the hood.

There is one more core concept in Swift’s type system: if a value is nil, its type must be wrapped as an Optional, and you have to explicitly acknowledge the optional case to use the wrapped value. There are a few ways to unwrap it, including a nil-coalescing operator (??) whose right operand is the value to use instead when the wrapped value is nil. You can chain optionals to deal with the unwrapping in a more convenient place: optionalString?.contains(“substring”) gives an optional boolean instead of a string. You can also unwrap the optional in a conditional, which puts the unwrapped value in scope:

1
2
3
if let s = optionalString {
    print(s) // s is NOT optional here
}

Swift also formalizes the notion of exiting early with the guard syntax, which is basically a conditional that must exit the current scope if it evaluates to false. It works great with optionals because it puts the unwrapped value in scope outside of the guard statement.

1
2
3
4
5
guard let s = optionalString else {
   // Maybe throw an error or return here
}

print(s) // s isn’t optional for the rest of this scope! So convenient!

Another great safety feature is the requirement that switch statements must be exhaustive. For enums, that means you must handle every case of the enum. For other types where you can’t (or don’t want to) be exhaustive, you have to supply a default case.

One of my longstanding gripes with most programming languages and their documentation is ambiguity about whether and what types of errors a function throws. Swift partly addresses this by requiring that any function that throws an error include the keyword throws in its signature. If you call a function that throws, you have to call it with try and include throws in the signature of the calling function or handle the error by wrapping the try call in a do…catch block. The effect is that errors can’t bubble up without you addressing them. You can also use try? to handle the error by wrapping the return value in an optional that will be nil if an error is thrown.

Swift has a deep concurrency safety model that would take a lot of words to describe briefly, but suffice it to say that if there are data race issues in your project, it is unlikely that Swift didn’t try to help you avoid them along the way.

Our deployments are incredible

Sean should really write a whole post about this, and I suspect he will. One of the things I love about our deployments is that we always promote an existing QA container to production. The only difference in environment is a handful of variables that are easy to audit. It is possible for runtime crashes to occur, but there are very few places where that can happen. One of the ways that we avoid crashes is that we validate the presence of all expected environment variables on startup, so a build will fail to replace the previous deployment during the health check if some expected configuration is missing.

Because production deployments only involve tagging an existing container to promote it, they are very fast. Usually on the order of two minutes, most of which is spent verifying the health of the launched containers.

Our deployment containers are lean because we also run a separate build container. Only the compiled Swift executable and some static resources (mostly SVG files that we inline) are copied to the deployment container. Thanks to Swift our builds are incremental, and most objects are cached, which allows us to build and run tests on every PR and to produce a deployment container for every push to main, while maintaining a very modest GitHub Actions budget. We only build the executable once, and we reuse it for every environment, so there are no moving parts to get wrong when we deploy.

Expressive syntax

Swift has loads of cool features that make it a pleasure to work with. The previously mentioned guard let syntax is a banger that we season the code with very liberally.

Another favorite is extensions. An extension allows you to attach new behaviors to a type separate from its initial declaration. This can be handy for organizing our own code, but we can also attach behaviors to types from other libraries or even the standard library.

Finally, I need to mention protocols and generics in Swift, because they are really powerful. Swift protocols work like most languages, allowing you to define the public interface that a type must provide. Any of the Swift primitives (enums, structs, classes, and actors) can implement a protocol, and you can even create an extension of a protocol to supply default implementations for conforming types.

Generics allow you to write code that works with many types without writing redundant code for each one. A useful example of generics is the Array type, which has a generic associated type called Element. Because Element is generic, it can be practically anything. An array of Strings or Ints, or Customers, or Invoices.

The right accessories

Swift comes packed with a ton of great functionality in its standard library and Foundation (which is like the standard library’s bonus tracks). A lot of that functionality is only a protocol implementation away.

Take, for example, sorting an Array of invoices. By implementing the Comparable protocol on Invoice (which just indicates when one Invoice should sort as less than another) we get a sorted method on Array<Invoice> automatically. Even more exciting is that by implementing the Codable protocol (which usually requires no additional implementation beyond declaring it) we can serialize an Invoice object to and from JSON, x-www-form-urlencoded, or even the weird typed JSON data structures used by DynamoDB APIs.

Another handy feature is that Swift comes with date and decimal formatters, and by adding extensions to Date.FormatStyle and Decimal.FormatStyle, we can use customized formatters that are as easy to reach for as the built-in formatters.

Swift doesn’t come with everything, but the community has already filled a lot of gaps. Apple has put out a package of really nice HTTP Request and Response types, which are used by the Hummingbird web framework (which is the Swift analog to Python’s Flask). Soto is a community-driven AWS client library, that is far and away better than Amazon’s own implementation (and adds that DynamoDB Codable support that I mentioned).

One of my new favorite community packages is Elementary, a DSL for producing HTML documents from Swift types. While having to compile all of our HTML templates with the project is a bit slower, it extends the type safety we get everywhere else all the way into our templates, which has already reduced bugs. It also happens to be much faster at runtime than the dynamic, text-based alternatives we tested.

Future directions

Finally, I want to mention a practice that is enabled by Swift, but isn’t really part of it: type-driven design.

The idea is simple: we model problems in such a way that the type checker can validate some of our logic for us.

For example, we need to verify users’ email addresses. We’ve all probably written code like this before:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func sendEmail(_ email: String) {  }

struct User {
	let email: String
	let emailConfirmed: Bool
}

if user.emailConfirmed {
	sendEmail(user.email)
}

But consider the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
enum Email {
	case unconfirmed(String)
	case confirmed(ConfirmedEmail)
}

struct ConfirmedEmail {
    let email: String
    let confirmationProof: String
}

struct User {
	let email: Email
}

func sendEmail(_ email: ConfirmedEmail) {  }

if case .confirmed(let confirmedEmail) = user.email {
	sendEmail(confirmedEmail)
}

We have an enum called Email with two cases: unconfirmed and confirmed, each with an associated value. One of the handy uses for enums in Swift is to allow for a single type that wraps multiple underlying types. In this case, unconfirmed wraps a string, and confirmed wraps a ConfirmedEmail type, that includes proof of its confirmation.

That part is crucial because it helps us prevent mistakes. I can’t get a ConfirmedEmail type without including proof of its confirmation or lying about it. The other important part is to note that the sendEmail function only accepts a ConfirmedEmail, so I can’t call that function with anything else.

The last bit of code is some special Swift syntax that extracts the ConfirmedEmail value from an instance of the enum, and puts it in scope inside of the conditional so that I can use it to call my sendEmail function.

It might seem like a lot of boilerplate for checking whether an email was confirmed, but we’re using this pattern in more and more places. Eventually it will safeguard the Invoice status state machine, ensuring that an invoice can only take valid paths between states.

This is related to how we use the RequestContext in Hummingbird. A RequestContext is a type that is attached to a particular router or sub-router in Hummingbird. It is instantiated based on the current request, before the request handler itself begins processing. We have several RequestContexts available that help us avoid duplicate logic, and ensure prerequisites are met before handling a request. For example, if you visit somestudioname.studioworks.app the request context looks up somestudioname in DynamoDB and adds the Studio object to the request context. If it can’t find it, it returns an error before we ever reach the route’s logic. If the route requires an authenticated studio user it’ll check that too.

Final thoughts

I’d like to close by saying that we expected Swift to be a worthwhile risk when we began the project, and I think we were right to take it. I found a lot to love in building web applications with Swift, but there were certainly challenges and disappointments too. One of the joys of this project has been joining a small but passionate and growing community, and I look forward to contributing to a healthy Swift web ecosystem.