The most convenient way to do this is usually to write your own type. Here is a quick working sketch:
import shapeless._ trait T[I, O] extends (I => O) trait Pipeline[P <: HList] { type In type Out type Values <: HList } object Pipeline { type Aux[P <: HList, In0, Out0, Values0 <: HList] = Pipeline[P] { type In = In0; type Out = Out0; type Values = Values0 } def apply[P <: HList]( implicit pipeline: Pipeline[P] ): Aux[P, pipeline.In, pipeline.Out, pipeline.Values] = pipeline implicit def onePipeline[I, O]: Aux[T[I, O] :: HNil, I, O, I :: O :: HNil] = new Pipeline[T[I, O] :: HNil] { type In = I type Out = O type Values = I :: O :: HNil } implicit def longerPipeline[I, O, P <: HList, Out0, Values0 <: HList]( implicit pipeline: Aux[P, O, Out0, Values0] ): Aux[T[I, O] :: P, I, Out0, I :: Values0] = new Pipeline[T[I, O] :: P] { type In = I type Out = Out0 type Values = I :: Values0 } }
And then (reformatted for clarity):
scala> Pipeline[T[String, Int] :: T[Int, Char] :: HNil] res5: Pipeline[T[String, Int] :: T[Int, Char] :: HNil] { type In = String type Out = Char type Values = String :: Int :: Char :: HNil } = Pipeline$$anon$2@38fd077c scala> Pipeline[T[String, Int] :: T[Char, Char] :: HNil] <console>:19: error: could not find implicit value for parameter pipeline: Pipeline[[T[String, Int] :: T[Char, Char] :: HNil] Pipeline[T[String, Int] :: T[Char, Char] :: HNil] ^
An invalid pipeline does not compile, and for a valid value, we get the correct conclusions for the endpoints and intermediate values.