Supercharge your Azure Bicep project with custom types

Lukas RottachLukas Rottach
10 min read

Some time ago, a cool new feature called "user-defined data types" was announced for Azure Bicep. With the release of "compile-time imports," this feature has become even better.

In short, user-defined data types offer the following benefits:

  • Great autocompletion experience for simple and complex objects

  • Reducing complexity by grouping multiple parameters into one object

  • Increase readability and maintainability of your code

Some features mentioned in this blog may be released under an experimental flag. Exercise caution and avoid using these features in your production environment.
You find the project including all code and information at https://github.com/lrottach/az-bicep-types-demo

Introduction

To comprehend how we can benefit from user-defined data types, we will create a simple module to deploy an Azure VM. The objective is to deploy the Azure VM itself including an undefined number of discs. We aim to achieve the flexibility to configure each disk within the deployment freely.

Ensure that you have updated your Bicep CLI and VSCode extension to the latest version before beginning to develop user-defined data types.

I would like to achieve the following goals with the development of my template

  • Preventing an infinite number of parameters from being channeled through different modules and main

  • Use of the module should be as simple as possible

  • Support the user by providing only allowed values and descriptions where needed

User-Defined data types

User-defined data types in Azure Bicep allow for the creation of custom, reusable types, enhancing modularity and readability in infrastructure-as-code. By defining types with specific constraints, developers can enforce consistent usage patterns and reduce errors across Bicep files. These custom types streamline the configuration of Azure resources, making code more maintainable and easier to understand​. In contrast to parameter objects, user-defined data types support auto-completion.

Project Overview

To explore the new Azure Bicep features, we will write a Bicep module to deploy an Azure VM with an unspecified number of Azure Disks. Since we do not know in advance how many disks a user will need, we must allow the flexibility to deploy as many disks as required. At the same time, we need to validate the input and prevent an incorrect configuration from breaking the deployment.
Note, that the deployed Azure Disks may vary in their configuration. Depending on the requirements, not all Azure Disks should be configured the same.

We could achieve a deployment like this without the new features. By utilizing object parameters in conjunction with arrays and possibly the count parameter, we can get a similar outcome.
A count combined with loops in Bicep works great when deploying a bunch of identical resources. Unfortunately, it is not possible to change properties during a count-driven for each loop.

Getting started

The final project will resemble something like this. Note that I created separate folders for modules and types.

.
└── src/
    ├── modules/
    │   ├── Microsoft.Compute/
    │   │   └── vm.module.bicep
    │   └── Microsoft.Network/
    │       └── vnet.module.bicep
    ├── types/
    │   ├── parameter.types.bicep
    │   └── outputs.types.bicep
    ├── main.bicep
    └── parameter.bicepparam

Create some data types

Let's start by creating our first data type in Bicep inside the parameter.types.bicep file. You could also create your types inside main.bicep or alongside your module code. However, I prefer to separate my code into dedicated files and import them as needed. We'll have a look at this later.

First, we will design a data type to represent our Azure VMs' data disk configuration using the type keyword. We will let the user choose the disk size and tier for each disk. To prevent errors, we will provide valid options for each property. You can define these values like this: 'option1' | 'option2'.

// Virtual Machine - Data disk config
@sealed()
type dataDiskType = {
  @description('Size of the data disk')
  diskSize: 128 | 256 | 512
  @description('Performance tier of the data disk')
  diskTier: 'Premium_LRS' | 'Standard_LRS' | 'StandardSSD_LRS'
}

See also how I can still use the @description decorator for each individual property to give the user more information about the property.

The type above is only valid for representing a single instance of a data disk configuration. Now, I will create another type and define it as an array of dataDiskType.

// Virtual Machine - Data disks array
type dataDisksType = dataDiskType[]

You could also solve this with a single data type by adding [] to the end of dataDiskType, but I prefer to separate it in this case.

Great... Now let's define the more complex data type representing our virtual machine configuration. Just like with the data disk type, we want to describe all our virtual machine properties we want to offer within a data type.

// Virtual Machine - Configuration
@export()
@sealed()
type vmParameterType = {
  @minLength(1)
  @maxLength(15)
  vmName: string
  vmSize: 'Standard_B4as_v2' | 'Standard_B8as_v2'
  vmOs: '2022-datacenter-azure-edition-hotpatch' | '2019-datacenter-gensecond'

  @description('Deploy Azure Monitoring Agent if set to true')
  enableMonitoring: bool

  osDiskSize: 128 | 256 | 512
  dataDisks: dataDisksType
}

Please note that I can use the common decorators @minLength() and @maxLength() to control the vmName here.

The last line of our type is also very interesting. I am defining a new property, dataDisks, and assigning the previously created type, dataDisksType, as its value. From that point on, the data disk configuration array is part of our VM configuration.

Make our types available

By default, all these data types are private. They can only be used within our current Bicep file. However, since we decided to create a dedicated file to host all our types, we need to make them available to other modules and even our main file.

The magic happens by adding the @export() decorator to our type. By doing this, we make our type available to other files.
Please note that I've added @export() only to vmParameterType and not to the other types. This is because there is no need to publish the types for data disk configuration. Those types are already included as part of our VM configuration.

Create a module

Let's create a module to deploy a virtual machine and add our custom data type.

Compile-time imports

Compile-time imports in Azure Bicep are a feature that allows you to share and reuse code across multiple Bicep files, enhancing modularity and reducing duplication. This feature is available in Bicep version 0.25.3 and newer.

We start by editing the file ./src/modules/Microsoft.Compute/vm.module.bicep, which will contain our module.
On the first line, we import the data type we marked with @export() in our parameter.types.bicep file earlier.

import { vmParameterType } from '../../types/parameter.types.bicep'

Here, I specify the exact type I want to import and the file where the type is located. There are different ways to use that import statement. You could also import all available types from a file by using something like this import * as shared from 'shared.bicep'. But in my case, I only want to import the types I need to build this module.

From this point on, the type is available in our current module file.

Deploying some resources

First, we want to add some parameters to let everyone know which properties are required to call this module.

// Target scope
targetScope = 'resourceGroup'

// Parameters
// ******************************

// Deployment parameter
param deploymentLocation string

// Virtual Machine parameter
param adminUsername string

@secure()
param adminPassword string
param vmProperties vmParameterType
param targetSubnetId string

To implement our new user-defined data type, let's add a parameter to our module by referencing the recently created custom type. Rather than choosing one of the familiar types like string, int, or array, we opt for our custom type created previously.

When describing our VM resource we now have access to the type and all of its properties using the parameter name.

resource vm 'Microsoft.Compute/virtualMachines@2024-03-01' = {
  name: vmProperties.vmName
  location: deploymentLocation
  identity: {
    ...
  }
  properties: {
    networkProfile: {
      ...
    }
    hardwareProfile: {
      vmSize: vmProperties.vmSize
    }
    storageProfile: {
      osDisk: {
        ...
        }
      }
      imageReference: {
        publisher: 'MicrosoftWindowsServer'
        offer: 'WindowsServer'
        sku: vmProperties.vmOs
        version: 'latest'
      }
      dataDisks: [
        for (disk,i) in vmProperties.dataDisks: {
          lun: i
          name: '${vmProperties.vmName}-dataDisk-${i}'
          diskSizeGB: disk.diskSize
          managedDisk: {
            storageAccountType: disk.diskTier
          }
          createOption: 'Empty'
        }
      ]
    }
    ...
  }
}

See how easy it is to build that for loop around our dataDisks array inside the vmProperties.dataDisks property. Even here, we have full IntelliSense for all properties.

To keep this example readable, I've shortened everything a bit. Check out the full module code on the mentioned GitHub page.

Finish everything up

Now that we've finished the VM module, we need to create our main.bicep file and call the module from there. Please note that we also need to import our data type if we want to use it inside our main.bicep file. This is especially important if we want to publish it as a parameter and make it usable from within a parameter file, for example.

import { vmParameterType } from './types/parameter.types.bicep'

// Target scope
targetScope = 'subscription'

// Parameters
// ******************************

// Deployment parameter
@allowed(['westeurope', 'switzerlandnorth'])
param deploymentLocation string
param rgName string

// Virtual Machine parameter
param adminUsername string
@secure()
param adminPassword string
param vmProperties vmParameterType // <-- Our user-defined data type

// Virtual Network parameter
...

// Resources
// ******************************

// Azure Resource Group
resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
  ...
}

// Modules
// ******************************

// Virtual Network module
// *** Simple Azure VNET to make this example work
module vnet './modules/Microsoft.Network/vnet.module.bicep' = {
  scope: rg
  ...
  }
}

// Virtual Machine module
module vm './modules/Microsoft.Compute/vm.module.bicep' = {
  scope: rg
  name: 'deploy-${vmProperties.vmName}'
  params: {
    deploymentLocation: deploymentLocation
    adminUsername: adminUsername
    adminPassword: adminPassword
    vmProperties: vmProperties
    targetSubnetId: vnet.outputs.defaultSubnetId
  }
}

Thanks to our data type, we can pass it in as a whole without needing to define dozens of individual VM parameters.

Now that we've finished our main.bicep file, we can start defining real values for our deployment. What's better for that than a .bicepparam file?

Here an example of how such a parameter file could look like:

using 'main.bicep'

// Deployment parameter
...

// Virtual Machine parameter
param adminUsername = 'admindeploy'
param adminPassword = 'Password1234!' // Don't do this in production :P

// Virtual Machine properties
param vmProperties = {
  vmName: 'vm-vmDb1-we'
  vmSize: 'Standard_B4as_v2'
  enableMonitoring: true
  osDiskSize: 128
  vmOs: '2022-datacenter-azure-edition-hotpatch'
  dataDisks: [
    {
      diskSize: 128
      diskTier: 'Standard_LRS'
    }
    {
      diskSize: 256
      diskTier: 'Premium_LRS'
    }
  ]
}

See how well the auto-completion works while defining our VM configuration. I love the experience, especially in parameter files. This is perfect for offering your modules to people who don't want to spend hours browsing your module code and just want to deploy it quickly.

Conclusion

A few final words. Personally, I use these data types a lot in my projects since their official release. I really like the idea of a defined and sealed data type that I can rely on throughout my code.

Of course, there are different ways to achieve similar results using data types. Since the beginning of Azure Bicep, and even before, we could write parameters as objects to handle more complex use cases. However, something like the following example is harder to use and maintain. The biggest downside of that approach is that there is no auto-completion for those types of objects coming from a parameter.

param diskConfig array = [
  {
    diskSize: 128
    diskTier: 'Standard_LRS'
  }
  {
    diskSize: 256
    diskTier: 'Premium_LRS'
  }
  {
    diskSize: 256
    diskTier: 'StandardSSD_LRS'
  }
]

Alternatively, we could define those types as variables, which brings back the auto-completion feature. But then we are no longer able to publish them as parameters.

To summarize, by adding user-defined data types and compile-time imports to your Bicep project, you introduce some extra complexity. However, I believe it's worth it because the code becomes easier to maintain and use.

In more complex projects, I recommend versioning your types alongside your modules. This way, you won't break your existing deployments when updating your data types. You simply introduce a new version.

More documentation

Inspiration

Most of this blog and code was written while listening to the albums 1 (The Beatles) and Billy Talent II (Billy Talent). Have fun.

1
Subscribe to my newsletter

Read articles from Lukas Rottach directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lukas Rottach
Lukas Rottach

I am an Azure Architect based in Switzerland, specializing in Azure Cloud technologies such as Azure Functions, Microsoft Graph, Azure Bicep and Terraform. My expertise lies in Infrastructure as Code, where I excel in automating and optimizing cloud infrastructures. With a strong passion for automation and development, I aim to share insights and practices to inspire and educate fellow tech enthusiasts. Join me on my journey through the dynamic world of the Azure cloud, where innovation meets efficiency.