Supercharge your Azure Bicep project with custom types
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
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.
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.
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
User-defined types in Bicep - Azure Resource Manager | Microsoft Learn
Compile-time imports in Bicep - Azure Resource Manager | Microsoft Learn
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.
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.