Parameter Packs in Swift 5.9

Deepika Srivastava
The Alchemy Lab
Published in
6 min readNov 16, 2023

At WWDC 2023, a new feature was introduced in Swift 5.9 called Parameter Packs. Before we delve into what Parameter Packs are, let's first see why they are required or what problem they solve.

Problem statement

Let's say we have a protocol DataMapper for mapping data and two concrete implementations of DataMapper. I have set up something simple such that we are not lost in the complexity of the code.

protocol DataMapper {
associatedtype Input
associatedtype Output
var input: Input { get }
func mapData() -> Output
}

struct StringOutput {
let stringValue: String
}

struct BoolOutput {
let boolValue: Bool
}

struct StringDataMapper: DataMapper {
var input: String
init(input: String) {
self.input = input
}
func mapData() -> StringOutput {
.init(stringValue: input)
}
}

struct BoolDataMapper: DataMapper {
var input: Bool
init(input: Bool) {
self.input = input
}
func mapData() -> BoolOutput {
.init(boolValue: input)
}
}

let stringMapper = StringDataMapper(input: "Test")
let boolMapper = BoolDataMapper(input: true)

So if we want to map data, we just call mapData() on stringMapper and boolMapper .

let stringOutput = stringMapper.mapData()
// StringOutput
// - stringValue : "Test"
let boolOutput2 = boolMapper.mapData()
// BoolOutput
// - boolValue : true

A generic function that does the same for us may look like below.

func mapValues<T: DataMapper>(_ item: T) -> T.Output? {
item.mapData()
}
let stringOutput = mapValues(stringMapper)
let boolOutput2 = mapValues(boolMapper)

The tricky bit starts when we want to extend this generic function to accept different type parameters and do mapping in one go. There are various ways to achieve this but yet no perfect way. We’ll explore the most common solutions.

Passing as an array in Generic function

func mapValuesWithArraySolution<T: DataMapper>(_ item: [T]) -> [T.Output?] {
item.map { $0.mapData() }
}

This all looks good until we try to invoke this method with an array of stringMapper and boolMapper . We see we are unable to do so.

The generic method is unable to resolve the type parameter.

Type Erasure

To solve the above, we would be tempted to use type erasure which lets us pass any type of DataMapper as arguments.

func mapValuesWithTypeErasure(_ item: [any DataMapper]) -> [Any] {
item.map { $0.mapData() }
}
let output = mapValuesWithTypeErasure([stringMapper, boolMapper])

However, in this case, the type information for the returning results is lost which leaves us output as Any objects. We have to type cast it back to make it useful.

Variadics

Variadics along with type erasure also allows us to pass any number of different type of DataMapper as arguments.

func mapValuesWithVariadics(_ item: any DataMapper...) -> [Any] {
item.map { $0.mapData() }
}
let output = mapValuesWithVariadics(stringMapper, boolMapper)

But same as type erasures, the type information for the returning result is lost which leaves us output as Any objects.

Overloading Pattern

This corners us to the solution of function overloading using generics. The overloading pattern allows us to pass different type parameters and then return values retaining their type information.

func mapValues<T1: DataMapper, T2: DataMapper>(
_ item1: T1,
item2: T2) -> (T1.Output?, T2.Output?) {
(item1.mapData(), item2.mapData())
}

let output = mapValues(stringMapper, item2: boolMapper)

This perfectly suits our needs. Now what if we want to pass 3 different types of DataMapper as arguments and then a few weeks later I want to add one more, we’ll end up having generic overloads like below.

func mapValues<T1: DataMapper, T2: DataMapper>(
_ item1: T1,
item2: T2
) -> (T1.Output?, T2.Output?) {
(item1.mapData(), item2.mapData())
}
func mapValues<T1: DataMapper, T2: DataMapper, T3: DataMapper>(
_ item1: T1,
item2: T2,
item3: T3
) -> (T1.Output?, T2.Output?, T3.Output) {
(item1.mapData(), item2.mapData(), item3.mapData())
}

func mapValues<T1: DataMapper, T2: DataMapper, T3: DataMapper, T4: DataMapper>(
_ item1: T1,
item2: T2,
item3: T3,
item4: T4
) -> (T1.Output?, T2.Output?, T3.Output?, T4.Output) {
(item1.mapData(), item2.mapData(), item3.mapData(), item4.mapData())
}
// and so on..

This approach has a few drawbacks:

  1. Forces us to choose an arbitrary upper limit to the number of arguments that will be supported.
  2. Redundancy.

We have seen this overload in Combine operators like zip, combineLatest, and merge.

And this is where Parameter Packs come to our rescue.

What are Parameter Packs?

As explained in this WWDC session, a parameter pack can hold any quantity of types or values and pack them together to pass them as an argument to a function and allow us to write one piece of generic code that works with every individual element in a pack. This distinguishes itself from collections because each element in the pack has a different static type, and we can operate on packs at the type level.

A pack that holds individual types is called a type pack. (Bool, Int, String)
A pack that holds individual values is called a value pack. (true, 5, “test”)

Normally, we write generic code that works with different concrete types by declaring a type parameter inside angle brackets.

func mapValues<T: DataMapper>

We can now declare a pack of type parameters with the keyword each.

func mapValues<each T: DataMapper>

and hence, instead of having a single type parameter, the function accepts each T type parameter. This is called a type parameter pack.

Generic code that uses parameter packs can operate on each T type parameters individually using repetition patterns. A repetition pattern is expressed using the repeat keyword, followed by the pattern type.

repeat each T
repeat (each T).Output

repeat indicates that the pattern type will be repeated for every element in the given argument pack. each serves as a placeholder that gets substituted with individual pack elements during each iteration. In our specific context, this mapping is represented as below.

(StringDataMapper, BoolDataMapper) -> (StringOutput, BoolOutput)

So, as we see, the outcome is a comma-separated list of types forming a value parameter pack. This allows us to pass any number of arguments. Since the function parameter and return type are both dependent types of the type parameter pack each T, the length of the function’s value parameter pack will consistently correspond to the number of elements in the returned tuple.

So the multiple overloads formapValues() that we were using before can now be condensed and written as below using Parameter packs.

func mapValues<each T: DataMapper>(_ item: repeat each T) -> (repeat (each T).Output) {
(repeat (each item).mapData())
}
let output = mapValues(stringMapper, boolMapper)

The concrete argument pack is inferred from the arguments at the call-site. Every concrete type for the placeholders each T is gathered from the argument list into a type pack. Essentially, the code execution involves iterating over all types within the type pack. The body of the function will operate on the item value pack using repetition patterns. To return the results in a tuple, we enclose the pattern expression in parentheses.

And that’s it. Now, we have a function that accepts a value pack, evaluates mapData() for every item in the value pack, and returns the result in a tuple.

One final challenge is that while the above function allows any number of arguments, if the value pack provided to the function is empty, the result will be an empty tuple, contradicting the purpose of having the function in the first place. To make this function accept at least one argument, we refactor our method, to look like below

func mapValues<FirstT: DataMapper, each T: DataMapper>(_ firstItem: FirstT, _ item: repeat each T) -> (FirstT.Output?, repeat (each T).Output?) {
return (firstItem.mapData(), repeat (each item).mapData())
}

Parameter Packs in action

We’ll further use parameter packs in the code we wrote so far to make it more flexible. Currently, our mappers are initialized with input. We’ll refactor the code to store the mapper pack and then refactor mapValues() to receive input as a value pack. This enables mappers to de-couple from inputs and spit output based on input provided in mapValues().

protocol DataMapper {
associatedtype Input
associatedtype Output
func mapData(_ input: Input) -> Output
}

struct StringDataMapper: DataMapper {
func mapData(_ input: String) -> StringOutput {
.init(stringValue: input)
}
}

struct BoolDataMapper: DataMapper {
func mapData(_ input: Bool) -> BoolOutput {
.init(boolValue: input)
}
}

struct Mapper<each T: DataMapper> {
var item: (repeat each T)
func mapValues(_ input: repeat (each T).Input) -> (repeat (each T).Output) {
return (repeat (each item).mapData(each input))
}
}

let boolMapper = BoolDataMapper()
let stringMapper = StringDataMapper()
let mappers = Mapper(item: (boolMapper, stringMapper))
let output = mappers.mapValues(true, "test")

We have created a new Mapper struct that stores DataMapper pack as a stored property by wrapping it in parentheses to make it a tuple value. Also now the mapValues() can accept a pack of each DataMapper’s Input type and we pass this input argument to every item’s mapData() method.

In summary, Parameter Packs enable us to work with various type parameters in a generic function at their type level. Importantly, they offer flexibility in the number of arbitrary arguments that can be passed to a generic function.

And that's a wrap! Hope you find this article helpful and understand some basics of Parameter Packs in Swift 5.9.

--

--