Generic Algorithms Without Constraints
06 Nov 2025It’s often a red flag if we see duplicated code and we generally try to reduce it as much as possible. When we control all the code, we can massage structures to make this task easier but we don’t always have this flexibility.
This post explores how to remove duplication when two functions share logic but act on unrelated types with no shared interface - using Kotlin and Swift as examples.
Let’s look at a more challenging case.
The Problem
The algorithm itself isn’t important here, but consider the following starting point:
struct InputA { let name: String; let tag: String? }
struct InputB { let name: String; let tag: String? }
struct Output {
let name: String
let tag: String
init(inputA: InputA, tag: String) {
self.name = inputA.name
self.tag = tag
}
init(inputB: InputB, tag: String) {
self.name = inputB.name
self.tag = tag
}
}
func processInputAs(inputs: [InputA]) -> [Output] {
inputs
.compactMap { input in
input.tag.map { Output(inputA: input, tag: $0) }
}
}
func processInputBs(inputBs: [InputB]) -> [Output] {
inputBs
.compactMap { input in
input.tag.map { Output(inputB: input, tag: $0) }
}
}data class InputA(val name: String, val tag: String?)
data class InputB(val name: String, val tag: String?)
data class Output private constructor(val name: String, val tag: String) {
constructor(inputA: InputA, tag: String) : this(inputA.name, tag)
constructor(inputB: InputB, tag: String) : this(inputB.name, tag)
}
fun processInputAs(inputs: List<InputA>): List<Output> {
return inputs
.mapNotNull {
it.tag?.let { tag -> Output(inputA = it, tag = tag) }
}
}
fun processInputBs(inputs: List<InputB>): List<Output> {
return inputs
.mapNotNull {
it.tag?.let { tag -> Output(inputB = it, tag = tag) }
}
}As shown above, both processInputAs and processInputBs are nearly identical.
The issue is that these two functions operate on different input types with no common supertype.
This means we can’t write a simple generic function with a constrained type parameter, because we have no common supertype to constrain against.
So how might we solve this duplication if our types did share a common shape?
When a Shared Constraint Exists
If InputA and InputB did have a common supertype we could trivially create a single function that handles them both.
protocol Input {
var name: String { get }
var tag: String? { get }
}
struct InputA: Input {
let name: String
let tag: String?
}
struct InputB: Input {
let name: String
let tag: String?
}
struct Output {
let name: String
let tag: String
init(input: Input, tag: String) {
self.name = input.name
self.tag = tag
}
}
func processInputs(inputs: [Input]) -> [Output] {
inputs
.compactMap { input in
input.tag.map { Output(input: input, tag: $0) }
}
}interface Input {
val name: String
val tag: String?
}
data class InputA(override val name: String, override val tag: String?): Input
data class InputB(override val name: String, override val tag: String?): Input
data class Output private constructor(val name: String, val tag: String) {
companion object {
operator fun <T: Input> invoke(input: T, tag: String) = Output(input.name, tag)
}
}
fun processInputs(inputs: List<Input>): List<Output> {
return inputs
.mapNotNull {
it.tag?.let { tag -> Output(input = it, tag = tag) }
}
}Unfortunately, in our original framing, InputA and InputB share no common supertype.
This can occur in situations where you don’t own the types as they come from a third-party library or maybe the types are generated with something like OpenAPI.
When I hit this situation, I tend to look at the two functions side by side and highlight the differences.
Reveal The Differences
For these functions the differences are going to be:
- The type of the argument
- How we access
tag(keep in mind the code looks the same butInputAandInputBare distinct types) - How we construct
Output.
In the listing below I’ve highlighted these areas with multiple ?s.
func processInputs(inputs: [??????]) -> [Output] {
inputs
.compactMap { input in
input.???.map { ??????(input: input, tag: $0) }
}
}fun processInputs(inputs: List<?????>): List<Output> {
return inputs
.mapNotNull {
it.????.let { tag -> ??????(input = it, tag = tag) }
}
}Solving the Problem
For the first point, we can’t avoid not knowing the type - there’s nothing common to constrain to. So we’ll have to just introduce a type parameter.
func processInputs<T>(inputs: [T]) -> [Output] {
inputs
.compactMap { input in
input.???.map { ??????(input: input, tag: $0) }
}
}fun <T> processInputs(inputs: List<T>): List<Output> {
return inputs
.mapNotNull {
it.????.let { tag -> ??????(input = it, tag = tag) }
}
}Next, we need to teach the function how to read the tag value.
As we only know we have a T we can provide a function that takes a T and returns the String? we expect.
func processInputs<T>(
inputs: [T],
tag: (T) -> String?
) -> [Output] {
inputs
.compactMap { input in
tag(input).map { ??????(input: input, tag: $0) }
}
}fun <T> processInputs(
inputs: List<T>,
tag: (T) -> String?
): List<Output> {
return inputs
.mapNotNull {
tag(it)?.let { tag -> ??????(input = it, tag = tag) }
}
}Finally, we need to teach the function how to create the new outputs.
func processInputs<T>(
inputs: [T],
tag: (T) -> String?,
makeOutput: (T, String) -> Output
) -> [Output] {
inputs
.compactMap { input in
tag(input).map { makeOutput(input, $0) }
}
}fun <T> processInputs(
inputs: List<T>,
tag: (T) -> String?,
makeOutput: (T, String) -> Output
): List<Output> {
return inputs
.mapNotNull {
tag(it)?.let { tag -> makeOutput(it, tag) }
}
}With this helper function in place we can update our original processInputAs and processInputBs functions to call through to the shared code.
Pulling it Together
func processInputAs(inputs: [InputA]) -> [Output] {
processInputs(inputs: inputs, tag: \.tag, makeOutput: Output.init)
}
func processInputBs(inputs: [InputB]) -> [Output] {
processInputs(inputs: inputs, tag: \.tag, makeOutput: Output.init)
}
private func processInputs<T>(
inputs: [T],
tag: (T) -> String?,
makeOutput: (T, String) -> Output
) -> [Output] {
inputs
.compactMap { input in
tag(input).map { makeOutput(input, $0) }
}
}fun processInputAs(inputs: List<InputA>): List<Output> {
return processInputs(inputs, InputA::tag, Output.Companion::invoke)
}
fun processInputBs(inputs: List<InputB>): List<Output> {
return processInputs(inputs, InputB::tag, Output.Companion::invoke)
}
fun <T> processInputs(
inputs: List<T>,
tag: (T) -> String?,
makeOutput: (T, String) -> Output
): List<Output> {
return inputs
.mapNotNull {
tag(it)?.let { tag -> makeOutput(it, tag) }
}
}Conclusion
We don’t always need shared supertypes to make algorithms generic. With a little upfront work teaching the algorithm how to access and create data, we can operate on unrelated types just as effectively. Both Kotlin and Swift make this especially clean through their support for passing method and initializer references - keeping our higher-order functions readable and expressive.