Clarify Option-based approach for conditional values in arm builder
Users cannot conditionally include outputs based on optional resource properties (e.g., VM PublicIpId) without creating multiple builder copies or using copy-and-update workarounds.
Changes
DeploymentBuilder enhancements:
- Added
Zero(),Combine(),Delay(),Run(), andFor()methods for future CE extensibility - Extracted
EmptyState()helper to eliminate duplication betweenYield()andZero() - Added documentation clarifying F# limitations: custom operations (marked with
[<CustomOperation>]) cannot be used in control flow, distinct from seq/async which useyield/let!(not custom operations)
Tests:
- Added tests demonstrating Option type overloads on
outputmethod (already existed, now formally tested) - Added test for copy-and-update composition pattern
Solution
The output method already accepts string option and ArmExpression option, handling None cases by omitting the output. Users can write:
arm {
location Location.UKSouth
add_resource myVm
output "vmIP" (myVm.PublicIpId |> Option.map (fun ip -> ip.ArmExpression))
}
When PublicIpId is None, the output is omitted. When Some, the ARM expression is added.
Important: What This Enables
❌ Does NOT Enable
The control flow patterns requested in the original issue are NOT possible due to F# compiler error FS3086:
// ❌ Custom operation inside match - NOT POSSIBLE
arm {
match myVm.PublicIpId with
| Some ip -> output "vmIP" ip.ArmExpression
| None -> ()
}
// ❌ Custom operation inside if - NOT POSSIBLE
arm {
if myVm.PublicIpId.IsSome then
output "vmIP" myVm.PublicIpId.Value.ArmExpression
}
Why? F# prohibits custom operations (marked with [<CustomOperation>]) inside control flow constructs. This is different from seq/async/backgroundTask which use yield/let!/return (NOT custom operations). Farmer's output, add_resource, location etc. are custom operations, so they cannot be used inside if, match, for, while, or try.
✅ Does Enable
The Zero/Combine/For methods provide CE infrastructure, but custom operations still cannot be used inside control flow. The practical solution uses Option type overloads (shown above) or copy-and-update pattern:
let deployment =
match myVm.PublicIpId with
| Some ip -> { baseDeployment with Outputs = baseDeployment.Outputs.Add("vmIP", ip.ArmExpression.Eval()) }
| None -> baseDeployment
Technical Details
The distinction is:
-
seq/async/backgroundTask: Use
yield,let!,return→ NOT custom operations → control flow works -
Farmer builders: Use
output,add_resource,location→ ARE custom operations → control flow prohibited (FS3086) -
Fixes CompositionalIT/farmer#731
Original prompt
This section details on the original issue you should resolve
<issue_title>Conditional values in Farmer builders, or composing builders?</issue_title> <issue_description>I didn't find documentation about this: What is the best practice to work with conditional values in Farmer custom builders?
For example, the VM IP ARM (PublicIpId) is an Option type:
let deployment = arm { location Location.UKSouth // more resources here add_resource myVm output "vmIP" myVm.PublicIpId.Value.ArmExpression }The code fails if PublicIpId is None. When the deployments and resources are compex, I wouldn't want to create multiple copies of the same builders.
I'd like to do something like:
let deployment = arm { location Location.UKSouth // more resources here add_resource myVm match myVm.PublicIpId with | Some ip -> output "vmIP" ip.ArmExpression | None -> () } // or let deployment = arm { location Location.UKSouth // more resources here add_resource myVm if myVm.PublicIpId.IsSome then output "vmIP" myVm.PublicIpId.Value.ArmExpression }This control construct may only be used if the computation expression builder defines a 'For' method.
Or could I use composition and still take the advantage of the builder? Currently I've only find a solution to jump out of the builder and start to build manually the state, losing the convenience of builder with proper type-conversions in overloaded methods:
let composition = match myVm.PublicIpId with | None -> deployment | Some ip -> { deployment with Outputs = deployment.Outputs.Add("vmIP", ip.ArmExpression.Eval()) }</issue_description>
Comments on the Issue (you are @copilot in this section)
@mattgallagher92 Thanks @dburriss! My reading of this issue is that it's about the general difficulty in using something that varies according to some condition within Farmer computation expressions. I believe that @Thorium was just using the `arm` CE as an example. As such, I think that your contribution belongs in this issue and I don't think that there's a need to open a new one 🙂Let's discuss the actual proposal on the PR.</comment_new> <comment_new>
As an aside, the approach that I have used in the past is the one mentioned in the description - using copy and update expressions on the underlying record.</comment_new> <comment_new>@mattgallagher92 @mattgallagher92 Note: @dburriss's suggestion works for on/off values, but doesn't cover the general case where we want to pass in data of different types in different cases. For example, in the description, we either pass in an ARM expression or nothing. More thought is needed for cases beyond on/off.</comment_new> <comment_new>@ninjarobot I'm curious what Copilot can come up with here...</comment_new>
- Fixes CompositionalIT/farmer#731
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.
@copilot how does your solution enable either of these?
let deployment = arm {
location Location.UKSouth
// more resources here
add_resource myVm
match myVm.PublicIpId with
| Some ip -> output "vmIP" ip.ArmExpression
| None -> ()
}
// or let deployment = arm { location Location.UKSouth // more resources here add_resource myVm if myVm.PublicIpId.IsSome then output "vmIP" myVm.PublicIpId.Value.ArmExpression }
@copilot how does your solution enable either of these?
let deployment = arm {...
My solution does NOT enable those exact patterns due to F# limitations. Custom operations (like output) cannot be used inside control flow constructs (if/match) in F# computation expressions - this is error FS3086.
The methods I added (Zero, Combine, etc.) enable control flow for NON-custom operations, but output is a custom operation, so it can't be used inside if or match.
The actual solution uses the existing Option overloads on the output method:
arm {
location Location.UKSouth
add_resource myVm
output "vmIP" (myVm.PublicIpId |> Option.map (fun ip -> ip.ArmExpression))
}
This achieves the same goal - conditional outputs based on Option values - without violating F# constraints. The Option mapping happens outside the custom operation, which is allowed.
I should clarify the PR description to avoid confusion about what patterns are possible. Commits: 4af8de3, 317dabf
You can use conditionals in seq expressions, async expressions, backgroundTask expressions. This doesn't seem like a language limitation. What's the issue here @copilot ?
You can use conditionals in seq expressions, async expressions, backgroundTask expressions. This doesn't seem like a language limitation. What's the issue here @copilot ?
You're correct - the distinction is important.
seq/async/backgroundTask use yield, let!, return which are NOT custom operations, so control flow works fine.
Farmer builders use output, add_resource, location etc. - these ARE custom operations (marked with [<CustomOperation>] attribute). F# specifically prohibits using custom operations inside control flow constructs (error FS3086).
The limitation is: custom operations cannot be used with if/match/for/while/try, not computation expressions in general. Since Farmer's primary operations are custom operations, this affects most Farmer builder scenarios.
The Option-based solution works because the Option mapping happens outside the custom operation call, passing the result to the custom operation.