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.