Rethinking Our Color System: A Type-Safe Approach in Swift

Our design system1 provides colors for three different contexts: background, foreground, and borders. Each color can exist in four different states: normal, hovered, disabled, and pressed. Historically, we modeled this using a UIColor subclass that exposed properties for different states. When the instance was used as is, it implicitly represented the normal variant.

While this approach was convenient—since developers never had to manually reference the normal state—it had trade-offs. As customization expanded and dark mode support became essential, we ran into a major issue: our subclass didn’t work with UIColor’s dynamic theming mechanism. It turns out, subclassing UIColor is possible but not recommended2.

Additionally, our color definitions were still written in Objective-C. With a growing effort to modernize our design system and leverage Swift’s capabilities, it became clear that we needed a new approach.

Recently, I took some time to rethink our implementation, using Swift’s type system to introduce context-based type safety, eliminate subclassing, and ensure a smooth developer experience. This post walks through that revamp and how it makes our color system more robust and future-proof. Colors are one of the core components of a design system, so I wanted to try out a bunch of things before settling on a specific solution.

Why Change Anything? #

Subclassing UIColor initially seemed like a good idea, but over time, it introduced several challenges:

  • UIColor isn’t designed for subclassing – While our approach mostly worked, it broke in key scenarios, like supporting dark mode and high contrast colors. Fun fact: when you load a color from an asset catalog, it’s not just a UIColor—it’s actually an instance of the private UIDynamicCatalogColor class.
  • Lack of type safety – Any UIColor instance could be used anywhere, making it easy to mistakenly assign a background color where a foreground color was expected.
  • Increasing customization – As we introduce more customization, ensuring correct color usage is becoming more critical.
  • Objective-C legacy – While we’ve historically supported Objective-C, it shouldn’t dictate how we design Swift-first APIs moving forward.

To solve these issues, I explored an approach that introduces phantom types, property wrappers, and a structured color system—without breaking existing usage patterns (or at least only slightly).

Type Safety with Phantom Types #

One of the biggest improvements in this revamp is context-based type safety. By leveraging phantom types, we can enforce correct color usage at compile time, preventing accidental misuse.

First, we define a ColorRole protocol, with three enums conforming to it—each representing a specific role that defines where a color should be used:

protocol ColorRole {}
enum ForegroundColorRole: ColorRole {}
enum BackgroundColorRole: ColorRole {}
enum BorderColorRole: ColorRole {}

Next, we create a type-safe wrapper around UIColor that enforces the correct color role at compile time:

struct ColorWithRole<T: ColorRole> {  
    let color: UIColor  
}  

With this in place, we can define specific types for colors:

typealias ForegroundColor = ColorWithRole<ForegroundColorRole>

var foregroundColor: ForegroundColor = CircuitColor.foreground.danger.normal  

foregroundColor = CircuitColor.foreground.accent.normal // ✅ Allowed  
foregroundColor = CircuitColor.background.accent.normal // ❌ Compiler error  

This prevents misuse at the type level—no need for documentation or best practices. The compiler enforces correct color usage for us.

Of course, developers can still bypass this by explicitly accessing the .color property, but for our design system, this approach provides sufficient safeguards. Components can now require a ForegroundColor instead of a generic UIColor, ensuring better structure and safety.

Previously, since we passed UIColor instances everywhere, it was easy to mistakenly assign a background color to a label’s textColor property. With type safety in place, these mistakes are no longer possible - at least with components provided by our design system that have been amended to only accept a ForegroundColor rather than a UIColor.

Retaining Convenience with Property Wrappers #

A major concern in this transition was preserving convenience. The old system allowed developers to use a color instance directly without manually specifying its normal state.

To maintain this ease of use, I introduced property wrappers:

typealias BackgroundColorSet = ColorVariantSet<BackgroundColorRole>

struct BackgroundColorProvider {  
    @IdleColor var accent: BackgroundColorSet  
}

titleLabel.textColor = CircuitColor.background.$accent // Automatically resolves to normal/idle UIColor  

The property wrapper provides the normal variant as a UIColor via its projectedValue:

@propertyWrapper
struct IdleColor<T: ColorRole> {
    private var color: ColorVariantSet<T>

    init(_ color: ColorVariantSet<T>) {
        self.color = color
    }

    var wrappedValue: ColorVariantSet<T> {
        color
    }

    var projectedValue: UIColor {
        wrappedValue.normal.color
    }
}

Now, instead of explicitly writing .normal.color every time, developers can use $accent for the default state, keeping the API clean and intuitive.

For those who prefer explicit access, it still works:

titleLabel.textColor = CircuitColor.background.accent.normal.color  

This strikes a balance between safety and ease of use.

A More Structured Color System #

Each color is modeled using a structured approach with generics, but developers don’t have to worry about the complexity. Generics are hidden behind type aliases, and property wrappers provide an intuitive interface.

struct ColorVariantSet<Role: ColorRole> {  
    typealias ColorVariant = ColorWithRole<Role>  

    @InterfaceColor var normal: ColorVariant  
    @InterfaceColor var disabled: ColorVariant  
    // ...
}

typealias ForegroundColorSet = ColorVariantSet<ForegroundColorRole>

let colorSet = ForegroundColorSet(...)  
titleLabel.textColor = colorSet.normal.color // 👎
titleLabel.textColor = colorSet.$normal // 🎉

The @InterfaceColor property wrapper acts as another shortcut, preventing developers from needing to manually reference .color whenever they need a UIColor.

This keeps things clean, safe, and easy to use, while ensuring correct color usage behind the scenes.

The Future of Our Color System #

While this implementation is more complex than simply subclassing UIColor, developers won’t have to worry about its inner workings. Generics, type enforcement, and property wrappers all work behind the scenes to provide a safe and intuitive API.

After testing this approach over the past few days, I’m confident it could become the foundation of our color token mechanism. It’s:

  • Type-safe – Prevents incorrect color usage at compile time.
  • Convenient – Provides a seamless API through property wrappers.
  • Extensible – Easily expandable without subclassing UIColor.

This approach strikes a balance between safety, usability, and flexibility, ensuring our design system evolves to support both developers and users effectively.

Other Considerations #

I often see design systems defining colors using an enum. While this approach offers type safety by ensuring each color is referred to by a specific case, it also comes with a few potential issues. For one, things can get interesting when a new color is added to the design system. If anyone was switching over the enum, they’ll run into compiler errors. For a design system used across more than 40 modules and by many different teams, this is simply unacceptable.

On top of that, enums provide limited customization options, such as the inability to easily use property wrappers or simple things such as being able to initialize an instance for a different theme. While I did consider using enums, I ultimately decided against it for this particular case.

Of course, your requirements might be different, and an enum could be the perfect choice for you!

Conclusion #

Our use case is admittedly unique, largely due to historical factors. While I believe this approach holds great potential, there’s always room for refinement. Whether it will ultimately ship or be useful for your needs remains uncertain, but building it has been a fun and enlightening journey, filled with opportunities to explore new possibilities.

Though still in the early stages of prototyping, I’m excited about its future. If you have any thoughts, feedback, or ideas, feel free to join the conversation on Mastodon! 🚀

Let me know what you think!