In Scala 2, with Shapeless, you can do this (Scastie):
def f[T <: Product, H <: HList](tupleOfFoos: T)(
implicit gen: Generic.Aux[T, H],
hev: LiftAll[({type E[T] = Foo =:= T})#E, H]
) = tupleOfFoos
LiftAll ensures there's an instance of Foo =:= X for every X in H, and gen makes sure that T and H are not completely unrelated types.
In Dotty, you can add an evidence parameter with a match type for this:
type Homogenous[H, T <: Tuple] = T match {
case EmptyTuple => DummyImplicit
case H *: t => Homogenous[H, t]
case _ => Nothing
}
def f[T <: Tuple](tupleOfFoos: T)(using Homogenous[Foo, T]) = tupleOfFoos
This will allow you to call f((Foo(), Foo(), Foo())) but not f((1, 2, 3)).
Homogenous is a recursive match type with a base case of EmptyTuple. If a tuple is empty, then it's not filled with non-Foos, so the type becomes DummyImplicit, which has an implicit already in scope. Otherwise, we check if it looks like (H, ...) / H *: t, in which case we need to check if the rest of the tuple (t) is also valid. If it doesn't match that second case, we know the tuple is invalid, in which case the result is Nothing, which sane people don't make implicit values of.
If you want to use context bounds, you can make an additional curried type (Scastie):
type Homo2[H] = [T <: Tuple] =>> Homogenous[H, T]
def f[T <: Tuple : Homo2[Foo]](tupleOfFoos: T) = tupleOfFoos
Unfortunately, I haven't been able to get it to work with a single curried type (Scastie):
type Homogenous[H] = [T <: Tuple] =>> T match {
case EmptyTuple => DummyImplicit
case H *: t => Homogenous[H][t]
case _ => Nothing
}