How to Pass an Array of Objects to a Bicep Module
By ResourcePulse Team · · 5 min read
Passing a single object to a Bicep module is straightforward. Passing an array of objects hits a wall almost immediately — Bicep accepts the parameter, then refuses to access properties on the items because it does not know the shape.
This post shows the problem, why it happens, and three working patterns ordered by how much type safety you want.
Calling a module for each environment
Say you have a module that creates a storage account, and you want to call it once per environment instead of duplicating the block three times:
var environments = [
{ name: 'dev', sku: 'Standard_LRS' }
{ name: 'prod', sku: 'Standard_GRS' }
]
module storageAccounts './storage.bicep' = [for env in environments: {
name: 'storage-${env.name}'
params: {
accountName: env.name
sku: env.sku
}
}]
If your module parameter is typed object or array, Bicep cannot confirm that env.sku exists. It will let you deploy but will show a linter warning or, depending on your Bicep version and settings, a build error.
Why Bicep rejects untyped array properties
Bicep is strongly typed. When you declare param configs array, it knows it has an array but nothing about what is inside it. Accessing .sku on an untyped array element produces BCP018 ("The value of type 'object' cannot be used as a type") or a similar warning depending on version.
The Microsoft Learn docs show object and array types but skip defining what is inside them — which is where most tutorials stop and where the actual problem lives.
Pattern 1: user-defined types (recommended, Bicep 0.21+)
User-defined types let you declare the exact shape of an object and use it as MyType[]. Bicep 0.21 is the Azure CLI default as of early 2026. Check yours with az bicep version.
Define the type and accept it in your module:
// storage.bicep
type storageConfig = {
name: string
sku: 'Standard_LRS' | 'Standard_GRS' | 'Premium_LRS'
location: string?
}
param config storageConfig
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: config.name
location: config.location ?? resourceGroup().location
sku: {
name: config.sku
}
kind: 'StorageV2'
}
Then in the calling template:
// main.bicep
type storageConfig = {
name: string
sku: 'Standard_LRS' | 'Standard_GRS' | 'Premium_LRS'
location: string?
}
param environments storageConfig[] = [
{ name: 'dev', sku: 'Standard_LRS' }
{ name: 'prod', sku: 'Standard_GRS' }
]
module storageAccounts './storage.bicep' = [for env in environments: {
name: 'storage-${env.name}'
params: {
config: env
}
}]
Pass the whole object as config. Bicep now knows the shape, the warnings disappear, and a typo like env.ssku becomes a compile error instead of a silent deploy failure.
You need to declare the type in both files unless you use a shared types file (see below).
Pattern 2: shared types file
Repeating the type definition in every file that uses it causes drift. The clean fix is a separate types file with the @export() decorator:
// types.bicep
@export()
type storageConfig = {
name: string
sku: 'Standard_LRS' | 'Standard_GRS' | 'Premium_LRS'
location: string?
}
// storage.bicep
import { storageConfig } from './types.bicep'
param config storageConfig
// main.bicep
import { storageConfig } from './types.bicep'
param environments storageConfig[]
module storageAccounts './storage.bicep' = [for env in environments: {
name: 'storage-${env.name}'
params: { config: env }
}]
@export() is required. Without it, imports from that file fail with "The target namespace does not contain an export named X."
This scales well when multiple modules share the same config shapes. One type definition, no drift.
Pattern 3: object[] when you want to ship fast
If you are on an older Bicep version or do not need strict type checking, object[] suppresses the errors and lets you access properties without Bicep validating them:
param environments object[] = [
{ name: 'dev', sku: 'Standard_LRS' }
{ name: 'prod', sku: 'Standard_GRS' }
]
module storageAccounts './storage.bicep' = [for env in environments: {
name: 'storage-${env.name}'
params: {
accountName: env.name
sku: env.sku
}
}]
This works. The trade-off: a typo in a field name will not be caught until deploy time. Fine for quick iterations, not ideal once the template is in production.
Getting outputs from a looped module
You cannot reference storageAccounts.outputs.id directly when the module is an array. Reference by index:
output devStorageId string = storageAccounts[0].outputs.storageAccountId
Or collect all outputs with another loop:
output allStorageIds array = [for i in range(0, length(environments)): storageAccounts[i].outputs.storageAccountId]
Cost impact of looped module deployments
Modules and loops make it easy to provision a lot of resources in one push. A parameter file with 10 environments creates 10 storage accounts in a single deployment. If the sku field defaults to Premium_LRS in a parameter file someone forgot to update, all 10 run at premium pricing.
The type declaration you just wrote is also a record of every cost-driving property in one place: name, sku, location. A code review that catches a Standard_GRS changed to Premium_LRS in the environments array before it merges is much cheaper than the Azure bill that arrives three weeks later.
If your team reviews Bicep changes on GitHub, ResourcePulse posts an estimated monthly cost for each resource added or changed, including looped module deployments. The Preview tier is free on one repository with no Azure subscription access needed.