Broadcast StepRange

Consider

times_dt = DateTime("2025-12-29T00:18:00"):Minute(1):DateTime("2025-12-29T22:21:00")
typeof(times_dt)  # StepRange{DateTime, Minute}

Consider broading on this.

DateTime.(times_dt)  |> typeof  # Vector{DateTime}

My first question is: is this intentional or overlooked? If it’s intentional, is it intentional because there’s the expectation that the return type broadcast(func, x) is always type Vector{something}? If we don’t have that constraint on the return type of a broadcast, should we specialize broadcast(DateTime, x::StepRange{DateTime, thing}) = x?

There’s perhaps a wrinkle here. I tried:

Base.broadcast(DateTime, x::StepRange{DateTime,S}) where {S} = x
DateTime.(times_dt)  |> typeof # Vector{DateTime}
broadcast(DateTime, times_dt) |> typeof  # StepRange{DateTime, Minute}

I thought that the dot syntax was syntactic sugar for calling Base.broadcast but apparently that’s not the case…? This doc [1] confirms my expectation, but that’s not what we see here…?

More generally, f.(args...) is actually equivalent to broadcast(f, args...)

[1] Functions · The Julia Language

1 Like

There are a few broadcasts over ranges which specialise to return another range, but not many. I don’t think there’s a very precise rule:

julia> 2 .* (1:3)  # also 2 * (1:3)
2:2:6

julia> (1:3) .+ (10:12.0)  # also (1:3) + (10:12.0)
11.0:2.0:15.0

julia> float.(1:3)  # but float(1:3) is a range
3-element Vector{Float64}:
 1.0
 2.0
 3.0

julia> Int.(1:3)  # trivial
3-element Vector{Int64}:
 1
 2
 3

The last one, like yours, broadcasts a type which matches the eltype, and could simply do nothing.

The dot and broadcast are equivalent in that they call the same functions. The special cases above overload broadcasted:

julia> using Dates

julia> times_dt = DateTime("2025-12-29T00:18:00"):Minute(1):DateTime("2025-12-29T22:21:00")
DateTime("2025-12-29T00:18:00"):Minute(1):DateTime("2025-12-29T22:21:00")

julia> Meta.@lower DateTime.(times_dt)
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope`
1 ─ %1 = Base.broadcasted(DateTime, times_dt)
│   %2 = Base.materialize(%1)
└──      return %2
))))

julia> @less broadcast(DateTime, times_dt)
# broadcast(f::Tf, As...) where {Tf} = materialize(broadcasted(f, As...))

julia> Broadcast.broadcasted(*, 2, 1:3)
2:2:6
1 Like

That’s just the default, and as mentioned already, there are custom implementations. One possibility is changing the general output array, but there’s also function-specific implementations. With ranges, it’s obvious that some functions wouldn’t preserve a constant step:

julia> (1:3) .* 2
2:2:6

julia> (1:3) .^ 2
3-element Vector{Int64}:
 1
 4
 9

They’re intended to be equivalent, but that doesn’t imply one is syntactic sugar for the other. You can check Meta.@lower for what syntactic sugar really does, and it’s allowed to vary across versions.

This identity-like implementation is not how broadcasting is intended to be customized, so it broke the equivalence. In general, you can’t assume you can extend a given function just by writing another method for it, you need to learn the API.

As for your specific example, it’s technically possible for broadcasted type constructors to just copy (not identity, that’s really bad for mutables), but I don’t think there’s much precedence for it, even in the simplest examples:

julia> Int.(1:3)
3-element Vector{Int64}:
 1
 2
 3

Unfortunately, it’s hit or miss whether some operation on a range gives a range back. Moreover, it’s inconsistent between broadcast vs map: some operations preserve range when you broadcast them f.(xs) (but not when map(f, xs)), some behave the other way. See an old issue about that at range map-broadcast inconsistency · Issue #54364 · JuliaLang/julia · GitHub.