For something more than an iterator, see (my) RectiGrids.jl package:
julia> using RectiGrids
# create a grid with named dimensions:
julia> G = grid(a=1:3, b=4:5)
2-dimensional KeyedArray(NamedDimsArray(...)) with keys:
↓ a ∈ 3-element UnitRange{Int64}
→ b ∈ 2-element UnitRange{Int64}
And data, 3×2 RectiGrids.RectiGridArr{(:a, :b), NamedTuple{(:a, :b), Tuple{Int64, Int64}}, 2, Tuple{Nothing, Nothing}, Tuple{UnitRange{Int64}, UnitRange{Int64}}}:
(4) (5)
(1) (a = 1, b = 4) (a = 1, b = 5)
(2) (a = 2, b = 4) (a = 2, b = 5)
(3) (a = 3, b = 4) (a = 3, b = 5)
# it behaves as an array, with NamedTuple elements:
julia> G[1, 2]
(a = 1, b = 5)
# even more, a grid is a KeyedArray and can use any of KeyedArray selectors
# this still doesn't materialize the whole grid:
julia> G(a = >(1)) # only keep values with a > 1
2-dimensional KeyedArray(NamedDimsArray(...)) with keys:
↓ a ∈ 2-element UnitRange{Int64}
→ b ∈ 2-element UnitRange{Int64}
And data, 2×2 view(::RectiGrids.RectiGridArr{(:a, :b), NamedTuple{(:a, :b), Tuple{Int64, Int64}}, 2, Tuple{Nothing, Nothing}, Tuple{UnitRange{Int64}, UnitRange{Int64}}}, 2:3, :) with eltype NamedTuple{(:a, :b), Tuple{Int64, Int64}}:
(4) (5)
(2) (a = 2, b = 4) (a = 2, b = 5)
(3) (a = 3, b = 4) (a = 3, b = 5)
# applying a function over the grid keeps the keyed indices (a and b):
julia> map(x -> x.a^2 + x.b, G)
2-dimensional KeyedArray(NamedDimsArray(...)) with keys:
↓ a ∈ 3-element UnitRange{Int64}
→ b ∈ 2-element UnitRange{Int64}
And data, 3×2 Matrix{Int64}:
(4) (5)
(1) 5 6
(2) 8 9
(3) 13 14