An exploration of FormatStyle as a Dependency
Buckle up. This is an adventurer journal while exploring what’s the best way of exposing the new style of formatting APIs in Foundation. We will walk through the paths of the type system, including existential types, generics and type erasure; and discover the magical possibilities of the dependency system.
This idea came to mind after finding a clashing in styles of development. When I work on my side projects and I have to do things quickly, mostly for fun, I love the new formatting APIs, thePast.formatted(.relative(presentation: .numeric, unitsStyle: .abbreviated))
. But then when working in a proper product, in a team, with a long-term vision, you need to use proper engineering techniques, which often involves doing dependency injection.
I use PointFree’s dependency library (as part of the TCA) which I’ve discussed previously (On the new Point-Free swift-dependencies library) but this applies to any other DI technique pretty much. Usually when you want to DI something that lets you format, the best way is to provide a function that takes the input value and gives you an output value, typically a String
.
public struct DateFormatter {
public let format: (_ date: Date) -> String
}
Then the live
implementation of this dependency can use a DateFormatter
or the new .formatted
style. The advantage of having this API as a dependency is that it allows you to very easily switch the implementation for testing and previews.
static var testValue: DateFormatter = XCTUnimplemented("\(Self.self).format")
static var previewValue: DateFormatter = { _ in "Tue, Nov 29" }
static var liveValue = { $0.formatted(...) }
Being able to just have a hard-coded value for previews or testing without having to deal with the formatters themselves is a tremendous benefit that can’t be underestimated.
But as nice as this is, I resented the usage side not being as nice and natural as using the Foundation fluent API.
formatDate(selectedDay.date)
It’s not bad, but I prefer the fluent API in this case. I just enjoy it a lot more when working on my side projects, so I started thinking how I could keep the joyfulness of the code but still adding the seriousness of DI.
FormatStyle instances
To start, I had to learn more about how the newly fluent API worked. I explored it in the past but Apple’s documentation is not super clear in this area, but then I was pointed to Gosh Darn Format Style! which does a great job of explaining how the API works.
The key is understanding that behind these fancy fluent APIs there is one protocol: FormatStyle
. All the dot syntax calls create types conforming to that protocol and fluently mutate them. In other words, you can hold an instance of the style that you want.
// wo this seems so complex!
(2.5).formatted(.number.decimalSeparator(strategy: .always).precision(.integerLength(1)))
// but is just really a mutation of structs
let numberStyle = FloatingPointFormatStyle<Double>.number
let withDecimalSeparator = numberStyle.decimalSeparator(strategy: .always)
let withPrecision = withDecimalSeparator.precision(.integerLength(1))
(2.5).formatted(withPrecision)
This seems obvious, but I think this API has so many combinations and you get so overwhelmed with the auto-completion, that is sometimes hard to realise the simplicity of it. I think the eye-opening moment is when you actually understand that the API surface is not in the extension of the input type but in the fluent extensions of the concrete FormatStyle
. The Date
, Double
, … types are not the ones with the complex API, they just have one method, the .formatted
call. It’s in their specific formatter styles that the complexity resides.
What this also means is that you can easily create your predefined instances of styles in order to reuse them in your application. I think this is key because otherwise the risk of slightly doing different formats in different views is too big.
extension FormatStyle where Self == FloatingPointFormatStyle<Double> {
public static var numberFancy: FloatingPointFormatStyle<Double> {
Self.number.decimalSeparator(strategy: .always).precision(.integerLength(1))
}
}
// Use numberFancy instead of the long chain from before
(2.5).formatted(.numberFancy)
Dependency inject FormatStyle
With this ability in our hands, let’s go back to defining how it would ideally work with dependency injection.
If we consider I would love to keep the fluent API and have DI the obvious usage looks like this:
@Dependency(\.numberFancy) var style
number.formatted(style)
The simple way of accomplishing this with the dependency system just involves declaring the dependency using the FloatingPointFormatStyle
type.
extension DependencyValues {
var numberFancy: FloatingPointFormatStyle<Double> {
get { self[NumberFancyFormatterStyleKey.self] }
set { self[NumberFancyFormatterStyleKey.self] = newValue }
}
}
private enum NumberFancyFormatterStyleKey: DependencyKey {
static let liveValue: FloatingPointFormatStyle<Double> = .number.decimalSeparator(strategy: .always).precision(.integerLength(1))
}
If you have used the Dependency system, this should be pretty familiar. We just declare the DependencyKey
and provide the property DependencyValues
to get the KeyPath
. The important part is how we can now declare the FloatingPointFormatStyle
we want and that is injected into our code, while it lets us still use the nice fluent syntax.
And we are done, right? Nope!
Hardcoding an output
Remember the advantage of using a function: we can easily provide test and preview values without having to deal with the formatters themselves. With the new setup we lost this ability, there is no way, to my knowledge, to mutate a style implementation like FloatingPointFormatStyle
and force it to return a custom string.
The alternative I pursued was to create a custom format style, after all, FormatStyle
is just a protocol. It may seem that each input type, like numbers or dates, is tied to a specific FormatStyle type, but that’s not true. It may be confusing because the types are nested and so it feels they are hardcoded, but in reality, the formatted
function is generic.
// From Foundation extension BinaryFloatingPoint
func formatted<S>(_ format: S) -> S.FormatOutput where Self == S.FormatInput, S : FormatStyle
This constrained function lets you pass any FormatStyle
with a FormatInput
associated type that it’s a BinaryFloatingPoint
. This means that it accepts a FloatingPointFormatStyle
but also a custom type with the correct associated types.
Let’s make a format style that always returns the same string:
struct HardcodedNumberFormatStyle: FormatStyle {
typealias FormatInput = Double
typealias FormatOutput = String
func format(_ value: FormatInput) -> FormatOutput {
return "hardcoded"
}
}
And we can use this to format the Double from before:
number.formatted(HardcodedNumberFormatStyle())
To make it look nicer, we should provide an extension to allow dot syntax as shown above.
This is all good, but how do we stuff this in our dependency system?
private enum NumberFancyFormatterStyleKey: DependencyKey {
static let liveValue: FloatingPointFormatStyle<Double> = ...
static let testValue = HardcodedNumberFormatStyle()
Of course, this doesn’t compile, the types of liveValue
and testValue
don’t match. But again, we don’t care about the specific types, just about the input and output types of the format style. So maybe if we use generics…
Welcome to type system land
If we change the type to some FormatStyle
the dependency is fine, but also useless. You can’t use the style because it’s not constrained, so the formatted function won’t accept it. The solution would be to specify the associated types like some FormatStyle
but sadly, the FormatStyle
protocol doesn’t offer primary associated types. So we can’t use generics at this level.
A solution that occurred to me is to create a wrapper type that dispatches to the correct formatter accordingly. But the question is how to decide the dispatch.
struct WrappedNumberFormatStyle: FormatStyle {
func format(_ value: Double) -> String {
return FloatingPointFormatStyle or HardcodedNumberFormatStyle
}
}
A typical way I would do this is by passing the correct style at init time. After all, the dependency system makes the decision for us.
private enum NumberFancyFormatterStyleKey: DependencyKey {
static let liveValue: WrappedNumberFormatStyle = .init(FloatingPointFormatStyle<Double>.number ...)
static let testValue: WrappedNumberFormatStyle = .init(HardcodedNumberFormatStyle())
}
But of course, this raises the question of how we store the style generically. Maybe we can try to make the type generic…
struct WrappedNumberFormatStyle<Wrapped: FormatStyle>: FormatStyle where Wrapped.FormatInput == Double, Wrapped.FormatOutput == String {
But even if the type itself compiles, we have solved nothing. Generics end up polluting the api surface and it means we are back at the same problem. Using a different Wrapped
type results in a different WrappedNumberFormatStyle
.
Another possibility is to keep the wrapped property type erased and enforce the correct types in the init, that’s a technique that works well when you want to erase some types.
struct WrappedNumberFormatStyle: FormatStyle {
private let wrapped: any FormatStyle
init<T: FormatStyle>(wrapped: T) where T.FormatInput == Self.FormatInput, T.FormatOutput == Self.FormatOutput {
self.wrapped = wrapped
}
But this doesn’t compile because the existential type itself doesn’t conform to Codable
, Equatable
and Hashable
, all protocol requirements inherited from FormatStyle
. This means we would need to type erase all the requirements of those protocols… and at that point I’m starting to lose interest.
But just for fun, let’s assume that we don’t care about those requirements right now and move on, hole driven development for the win. The next challenge is to make the format call compile.
func format(_ value: Double) -> String {
return wrapped.format(value)
}
The immediate problem is that since wrapped
is an existential, the return type of the format call is not constrained, it returns Any
. And since Any
is not String
it won’t compile. The fix here is easy: since we know the only way to create the wrapped
property is going through the init
that applies the constraints, we can assume the output type is a String
, so a force cast should be safe.
func format(_ value: Double) -> String {
return wrapped.format(value) as! String
}
But now we encounter another problem: Member 'format' cannot be used on value of type 'any FormatStyle'; consider using a generic constraint instead
. This error is a bit weirdly explained, but what is happening is that since wrapped
is an existential, even though it has the format
method defined, the input type of the format call can’t be known at compile time and thus the requirement of value: Double
being equal to the input type of wrapped
is not satisfied.
Here is where I got stuck. I’m able, thanks to the recent additions to opening existential, to create a generic function that opens the wrapped type. But I haven’t figured out a way to relate that with the input type. I was hoping the following worked…
private func _format<F: FormatStyle>(
formatStyle: F,
value: F.FormatInput
) -> F.FormatOutput {
formatStyle
.format(value)
}
func format(_ value: Double) -> String {
return _format(formatStyle: wrapped, value: value)
}
… but it can’t: Type 'any FormatStyle' cannot conform to 'FormatStyle'
. Again, the error is a bit misleading because the issue is really with the second parameter. If the _format
function just had 1 parameter formatStyle: F
the compiler is able to open the existential at runtime and is all fine. But we can’t link the type of the second parameter F.FormatInput
to another value and keep the relation with the first one.
I was confused by this for some time. I even read the proposal that introduced this functionality twice. But in the end, it makes sense. I’m passing a Double
as the second parameter, but that has no relation with wrapped
whatsoever. I wonder if there is some trick here that I’m not aware of.
The only way I know of making this compile is to add more force casts. And even though they should be safe since the init
constraints the types of the wrapped instance, it feels too much trouble.
Less generic alternative
So I tried to come up with an alternative. The idea of passing the type from the outside is nice, especially because I would have loved to have a generic format style wrapper that could reduce boilerplate; but if that didn’t work, I wanted to try a less generic approach.
Let’s still keep the dispatch decision on the dependency system, but specify the types of the formatters we need. Keeping the types concrete would solve the problem we have. For this I decided to switch to an enum with associated types since it felt like the quickest solution.
enum WrappedNumberFormatStyle: FormatStyle {
case foundation(FloatingPointFormatStyle<Double>)
case hardcoded(String)
case unimplemented
func format(_ value: Double) -> String {
switch self {
case .foundation(let floatingPointFormatStyle):
return floatingPointFormatStyle.format(value)
case .hardcoded(let string):
return string
case .unimplemented:
return XCTestDynamicOverlay.unimplemented()
}
}
}
There are no generics in this enum. All types are concrete, so we don’t have any problems with the type system. We exchange a sort of dynamic dispatch with a static and manual dispatch instead. I also took the opportunity to remove the HardcodedNumberFormatStyle
type since a String
in the hardcoded
does the same job, and also add the unimplemented
functionality for tests.
With this, the dependency integration is quite simple:
private enum NumberFancyFormatterStyleKey: DependencyKey {
static let liveValue: WrappedNumberFormatStyle = .foundation(FloatingPointFormatStyle<Double>.number.decimalSeparator(strategy: .always).precision(.integerLength(1)))
static let testValue: WrappedNumberFormatStyle = .unimplemented
static let previewValue: WrappedNumberFormatStyle = .hardcoded("hardcoded")
}
This works!
Now, I admit that seeing case foundation(FloatingPointFormatStyle
still makes me want to make it generic but we’ve been through that rabbit hole so there is no need to repeat it 😂
Locale for testing
One of the big benefits of having the dependency system in place is that we can solve one of the annoying parts of formatters: dealing with the locale. It’s true that with the code above we can control the format style dependency itself, but if we wanted to still use the real one for testing, we would still have to deal with the locale. A very nice thing we can do is use the @Dependency
ourselves in the implementation.
func format(_ value: Double) -> String {
@Dependency(\.locale) var locale
switch self {
case .foundation(let floatingPointFormatStyle):
return floatingPointFormatStyle
.locale(locale)
.format(value)
With this minor change, we now have a formatter that automatically gets the locale, like the default format styles, but with the difference that we can control that dependency.
A bit more magic
Seeing how we can access the dependencies internally, there is another trick that you can employ. Be ready for some magic 🧙♀️ .
The way that PointFree dependency system decides which value to provide is using a context value, that is itself part of the dependency system 🤯. This is very powerful because it means you can change the context yourself, or read it and leverage the internal logic of the library.
Thanks to this, we can actually have a type that magically changes.
struct MagicNumberFancyFormatStyle: FormatStyle {
func format(_ value: Double) -> String {
@Dependency(\.context) var context
@Dependency(\.locale) var locale
switch context {
case .live:
return FloatingPointFormatStyle<Double>.number.decimalSeparator(strategy: .always).precision(.integerLength(1))
.locale(locale)
.format(value)
case .preview:
return "hardcoded"
case .test:
return XCTestDynamicOverlay.unimplemented()
}
}
}
In fact, you can use this trick and expose the format style as an extension, without going through DependencyValues
at all! It means that (2.5).formatted(.numberFancy)
would look exactly normal, like any other Apple API, but under the hood you made your code fully testable and controllable. This is a huge win in my opinion, and I’m really tempted to use this trick directly without having to deal with the @Dependency in the usage side.
Of course this might be too much magic. Analyze the tradeoffs and decide by yourself if it’s worth.
Where does the path end?
Here is where I stopped my exploration. It was fun, and I learned a bunch of things again. We still need to decide if these wrapped types are worth the effort or if we will just keep the function style as the dependency. But for my side project usages I’m very tempted to use the magic solution :)
As always, thanks for coming with me in this journey, I hope you learned like I did. And if you know of other tricks or solutions that could get me the requirements I want, don’t hesitate to tell me 😜