You could replace fs::Vector{Function} with fs::Tuple{Vararg{Function} and pass a tuple of functions rather than an array. That will allow the compiler to know the exact types of the functions. Unfortunately, the return types do not seem to be inferred in that case: the fact that there is a loop over the functions does not appear to be handled (yet).

@inline g2(fs::Tuple{Function, Vararg{Function}}, x::Float64) = _g2(0.0, fs, x)
# _gs is a "private" method that processes the first call, then discards that function and recursively calls itself
@inline _g2(a, fs::Tuple{Function, Vararg{Function}}, x::Float64) = _g2(a + fs[1](x), Base.tail(fs), x)
# But we have to terminate the recursion. This method is called when we've "used up" all the functions
_g2(a, ::Tuple{}, x::Float64) = a
@code_warntype g2((sin, cos, tan), 3.14)

You’ll see this is type-stable even without any of the type-assertions/declarations.

How does compile time scale with the number of functions and is there a threshold where this does not work? Would try it myself but I don’t have access to a computer for a while.

I’ve got an unregistered package Unrolled.jl with generated functions that achieve the same thing, but without the quadratic compile-time, and for arbitrary-long tuples:

The definitions are straight-forward, as far as these things go:

@generated function unrolled_map(f, seq::Tuple)
:(tuple($((:(f(seq[$i])) for i in 1:type_length(seq))...)))
end
@generated function unrolled_reduce(f, v0, seq)
niter = type_length(seq)
expand(i) = i == 0 ? :v0 : :(f(seq[$i], $(expand(i-1))))
return expand(niter)
end