Skip to main content

Shining a spotlight on Terraform data types - Part 1

·2928 words·14 mins
Terraform and Azure - This article is part of a series.
Part 3: This Article

Originally planned as a single post, it was getting a little large so its been split into two parts. They’re both focused on theory but don’t let that put you off; I’ve really enjoyed writing them so hopefully you’ll enjoy reading them! It can really help to know how things are operating under the hood before we add additional complexity to our deployments.

There’s a lot in here so don’t worry if some things don’t make complete sense - right now, knowing of these concepts is the main goal.

What are data types?
#

When we create resources using Terraform usually some information has to be provided, such as resource names and configuration details for your deployment. Most programming languages have a type system which allows you to declare that these pieces of information should be a certain type, such as number (e.g. number_of_virtual_machines = 3), and trying to assign it a value that isn’t a number should cause an error.

Data types (or just types in most conversations) are a useful feature to validate the information being received adheres to certain rules before executing code or deploying the Terraform.

The table below shows some of the more common types available within Terraform. Note the data type column are not types you can assign to a variable, they are the grouping term for the attribute types in the next column.

Data typeAttribute typesDeclare withExample
Primitive- string
- bool
- number
- ""
- n/a
- n/a
"some text"
false
23
Complex - Collection- list
- map
- set
- []
- {}
- []
See Complex collection
Complex - Structural- object
- tuple
- {}
- []
See Complex structural

As long as you have the Terraform extensions installed, triggering the auto-complete/intellisense on the type portion of a variable block should show a list of type constructors like this:

attribute_types

If you’re after more detail I highly recommend the HashiCorp docs on types and type constraints

What about keys and values?
#

Info

Key-value pairs might also be referred to as dictionaries, hash maps, or just maps; in general conversation it’s unlikely to be too pertinent so establish some shared understanding for yourself and others then crack on.

Key-value pairs are just "some_text" = "some_value" - that’s it. Here’s some more examples in a Terraform locals block:

locals {
    fruit               = "apple"
    charlotte = {
      favourite_food  = "sandwiches"
    }
}

The key is the part on the left, the value is the part on the right. The key must always evaluate to a string type, though the value can be any supported attribute type that we saw in the table above.

Within the Terraform code, you can reference the value of these keys by using local.<key_name>, or if there’s sub items local.<key_name>.<key_name>, for as many keys as you need to.

E.g. using local.fruit will return the value "apple". Using local.charlotte["favourite_food"] will return "sandwiches". Using the [] syntax is a standard way to refer to keys, we’ll see more of that later.

Types (of types)
#

Good thing this isn’t confusing at all! When it comes to how things are typed in Terraform there’s two methods, and using language agnostic terms they are explicit (directly express something) and implicit (something is assumed/suggested/inferred).

Type constraints (explicit)
#

Note

Type constraints or type enforcements are the Terraform terms for the general concept of strong typing, which may also be referred to as explicit typing, static typing, and many others; they all refer to similar practice of specifying what kind of data variables can accept.

Within Terraform variable blocks you can declare type constraints using type constructors which helps you set out rules for what kind of data you will allow into the variable. This can be done using the type attribute, such as:

variable "some_boolean_variable" {
  type    = bool
  default = 23
}

This variable type is bool, which means it can only accept one of true, false, "true", or "false" as a value. Attempting to run a terraform validate over this code will give an error like the below because we are attempting to assign an incorrect value given the specified type constraint:

│ Error: Invalid default value for variable

│ on xx.tf line xx, in variable “some_boolean_variable”:
│ xx: default = 23

│ This default value is not compatible with the variable’s type constraint: bool required, but have number.

Info

If you thought the "true"/"false" values being allowed on a boolean type was odd, good catch! This is because Terraform’s automatic type conversion is being sneaky and converting values. Terraform will convert a string value of "true"/"false" to the boolean value true/false.

Inferred types (implicit)
#

In contrast to strong types, an inferred type is when Terraform makes a best effort guess as to what the data type might be. A quick reminder for the examples below that in a locals {} block, values are on the left side of the = and the expressions are on the right side.

locals {
  some_number = "23"
  
  todo_list = ["acquire snacks", "prepare snacks", "eat snacks"]

  permissions_object = {
    admin = {
      name = "Alice"
      age  = 30
    }
  }
}

Terraform will set these value types as:

  • some_number will be a string type because the expression is enclosed in double quotes ("") which indicates a string
  • todo_list will be a type tuple([string, string, string,]) and not a list(string) because we have wrapped the expression in square brackets [] which declare either lists, tuples, or sets - more on this later
  • permissions_object will be an object({...}) type because we have declared it using the curly braces {} which declare objects or maps

If you’re wondering if this behaviour is taking some liberties and you would prefer to set what kind of types you’re working with, there is hope! Enter the Terraform type conversion functions!

These allow you to inform Terraform what the type should be instead of letting it guess - let’s apply them to the previous local values:

locals {
  some_number = tonumber("23")
  
  todo_list = tolist(["acquire snacks", "prepare snacks", "eat snacks"])
  
  permissions_object = tomap({
    admin = {
      name = "Alice"
      age  = 30
    }
  })
}

So we’ve made use of the tonumber(), tolist(), and tomap() type conversion functions which now mean:

  • some_number will be a number
  • todo_list will be a list
  • permissions_object will be a map(object({...}))

In isolation this might sound a bit dull, but it’s really useful (and interesting!) once you start declaring and transforming more complex objects.

Complex collection
#

Now that we’ve rounded out a bit of theory on data types and setting constraints, lets look at some typed variable blocks. I’ll also display the JSON version, as I found this really helps wrap my head around data as it gets a bit more complex (especially when we start looking at loops/iterations).

Lists
#

Some neat facts about lists are:

  • They’re ordered, which means moving the position of items might trigger a deployment change
  • They can contain duplicate values, ["bob, "bob", "bob"] will be treated as 3 elements
  • All values must be the same type, as an example you can’t have a list which contains strings and objects
  • Lists can be joined together using the concat() function
  • List values can be accessed using:
    • index lookup syntax: var.my_list[<index number>]
    • element() function lookup: element(var.my_list, <index number>)

Check out these examples to see what basic list variables look like:

Example 1 - list of strings
variable "list_string_variable" {
  type        = list(string)
  description = "A list of items to purchase from the shop."
  default     = ["beans", "ice-cream", "oats"]
}

and it’s JSON representation:

[ "beans", "ice-cream", "oats" ]

Examples of accessing values for var.list_string_variable:

  • var.list_string_variable[0] would return beans
  • element(var.list_string_variable, 1) would return ice-cream
Example 2 - list of objects
variable "list_object_variable" {
  type = list(object({
    name = string
    age  = number
  }))
  description = "A list of people."
  default = [
    { name = "Pradeep", age = 30 },
    { name = "Sertan", age = 25 },
    { name = "Anya", age = 28 }
  ]
}

and it’s JSON representation:

[
  { "name": "Pradeep", "age": 30 },
  { "name": "Sertan", "age": 25 },
  { "name": "Anya", "age": 28 }
]

Examples of accessing values for var.list_object_variable:

  • var.list_object_variable[0].name would return Pradeep
  • element(var.list_object_variable, 1).name would return Sertan

Maps
#

I think maps are great - I’m confident you will come to love them also. Some neat facts about maps are:

  • They have no guaranteed order - it’s a feature not a bug; if you need to preserve order, use a list
  • Maps always have keys, and keys are always strings
  • Maps can be merged together using the merge() function
  • Map values can be accessed using:
    • key reference syntax: var.my_map["key"]
    • lookup() function: lookup(var.my_map, "key", "default_value_if_key_not_found")

Check out these examples to see what basic map variables look like:

Example 1 - map of strings
variable "resource_tags" {
  type        = map(string)
  description = "A map with string keys and values"
  default = {
    owner       = "team-a"
    cost_centre = "123"
    env         = "test"
  }
}

and it’s JSON representation:

{
  "owner": "team-a",
  "cost_centre": "123",
  "env": "test"
}

Examples of accessing values for var.resource_tags:

  • var.resource_tags["owner"] would return team-a
  • lookup(var.resource_tags, "cost_centre", "000") would return 123 if present (and 000 if not)
Example 2 - map of objects
variable "virtual_machines" {
  type = map(object({
    size     = string
    os       = string
    region   = string
  }))
  description = "A map of virtual machines to create."
  default = {
    web_server = { size = "small", os = "ubuntu", region = "uk south" }
    db_server  = { size = "large", os = "ubuntu", region = "uk south" }
    dev_box    = { size = "small", os = "windows", region = "uk west"  }
  }
}

and it’s JSON representation:

{
  "web_server": { "size": "small", "os": "ubuntu",   "region": "uk-south" },
  "db_server":  { "size": "large", "os": "ubuntu",   "region": "uk-south" },
  "dev_box":    { "size": "small", "os": "windows",  "region": "uk-west"  }
}

Examples of accessing values for var.virtual_machines:

  • var.virtual_machines["web_server"].size would return small
  • lookup(var.virtual_machines, "dev_box", "unknown").os would return windows if present (and unknown if not)

Sets
#

Sets might not be something you reach for all the time, but given their unique behaviour it’s great to know that some neat facts about sets are:

  • They are unordered and automatically remove duplicate values - ["bob", "bob", "bob"] will become just ["bob"]
  • Set values cannot be accessed directly, you cannot use var.my_set[0] like you would with a list type
    • Accessing a specific value requires iterating over it with a for_each, or using a tolist() type conversion function such as tolist(var.my_set)[0] - special quirk here, index 0 may not be what you think it is - check out the example to see why.
  • Sets can be merged together using the setunion() function
  • set types require all attribute values to be known at the plan stage so may not be suited for anything that would only resolve during an apply

Here’s one basic example for a set variable:

Example - set of strings
variable "set_string_variable" {
  type        = set(string)
  description = "A set of strings"
  default = [
    "gamma"
    "alpha"
    "beta"
  ]
}

and it’s theoretical JSON representation:

[ "alpha", "beta", "gamma" ]

Examples of accessing values for var.set_string_variable:

  • tolist(var.set_string_variable)[0] would return alpha
Note

Wait, why did you say theoretical JSON representation there and why would index 0 return alpha, shouldn’t it be the first item, gamma?

Remember sets are unordered, and lists are ordered. When the set is converted to a list it is sorted lexicographically which for right now, means alphabetically. So the converted list order becomes ["alpha", "beta", "gamma"] and index 0 returns alpha.

Complex structural
#

Objects
#

I love object. Some neat facts about objects are:

  • They allow you to declare a data structure!
  • Objects can be merged together using the merge() function
  • Object values can be accessed using:
    • dot notation: var.my_object.attribute

The autocomplete can look like this when declaring the variable in a *.tfvars file - pretty neat!

object_completion

Check out these examples to see what basic object variables look like:

Example 1 - object of strings
variable "simple_object" {
  type = object({
    name          = string
    age           = number
    height        = number
    town_of_birth = string
  })
  default = {
    age           = 54
    height        = 172
    name          = "Bob"
    town_of_birth = "Bob Town"
  }
}

and it’s JSON representation:

{
  "age": 54,
  "height": 172,
  "name": "Bob",
  "town_of_birth": "Bob Town"
}

Examples of accessing values for var.simple_object:

  • var.simple_object.name would return Bob
  • var.simple_object.height would return 172
Example 2 - object with complex types

Now this one is a little more interesting! We’re combining some of the previous types we’ve seen into a single variable.

variable "complex_object" {
  type = object({
    name      = string
    nicknames = list(string)
    details = object({
      age    = number
      height = number
    })
    locations_visited = list(object({
      date = string
      town = string
    }))
  })
  default = {
    name      = "Bob"
    nicknames = ["Bobby", "Bobster"]
    details = {
      age    = 54
      height = 172
    }
    locations_visited = [
      {
        date = "2023-01-01"
        town = "Bob Town"
      },
      {
        date = "2023-06-01"
        town = "Alice City"
      }
    ]
  }
}

and it’s JSON representation:

{
  "name": "Bob",
  "nicknames": ["Bobby", "Bobster"],
  "details": {
    "age": 54,
    "height": 172
  },
  "locations_visited": [
    { "date": "2023-01-01", "town": "Bob Town" },
    { "date": "2023-06-01", "town": "Alice City" }
  ]
}

Examples of accessing values for var.complex_object:

  • var.complex_object.name would return Bob
  • var.complex_object.nicknames[1] would return Bobster
  • var.complex_object.details.age would return 54
  • var.complex_object.location_visited[1].town would return Alice City

Tuples
#

Alright! Finally we get to see what was going on with the tuple([string, string, string,]) up in the inferred types (implicit) section.

Tuples are a bit of a catch all; very flexible but it would be uncommon, dare I say not recommended, to explicitly declare variables as tuple types. The ones we’ve already reviewed would likely be better choices. Tuples mostly appear on intermediate variables, such as the ones declared in the locals {} blocks.

That said, some neat facts about tuples are:

  • They’re ordered, similar to lists
  • They’re heterogeneous, i.e. they can have elements of different types; unlike lists and sets which are homogeneous where all elements must be the same type
  • They must have the same number of schema and elements - e.g. a tuple([string]) type can be given a value of ["first"], but not ["first", "second"]
  • Anything declared in a locals {} block where the expression uses [] will be a tuple unless you precede it with a type conversion function such as tolist([])
  • Tuples can be joined together using the concat() function but results may vary depending on the complexity of the tuple
  • Tuple values can be accessed using:
    • index lookup syntax: var.my_tuple[<index number>]
    • element() function lookup: element(var.my_tuple, <index number>)

Check out this example to see what a basic tuple variable looks like:

Example - mixed tuple
variable "my_tuple" {
  type        = tuple([string, number, bool])
  description = "A tuple with a string, a number, and a boolean"
  default     = ["example", 42, true]
}

and it’s JSON representation:

[ "example", 42, true ]

Examples of accessing values for var.my_tuple:

  • var.my_tuple[0] would return example
  • element(var.my_tuple, 1) would return 42

and as a bonus, when we use the type() function available in the terraform console (more on that in another post), we can see it knows these are all different types:

> type(["example", 42, true])

tuple([
    string,
    number,
    bool,
])

What’s all this about?
#

You made it! Hopefully you found some of that interesting, and you may be wondering why bother with all this theory stuff..

Firstly, these things will come up a lot in Terraform (and other languages) and are generally considered essential foundational knowledge - you’ll save yourself a lot of time by becoming familiar with these concepts.

Secondly, as we start to shift from creating one resources (singular) to N resources (multiple) we will be using the for expression and the for_each meta-argument, the latter of which can iterate (loop) over a map type.

As we learned about maps, they have keys! Whenever you use for_each, keys are going to be involved (whether you like it or not). We’ll explore this more in another post, but when you start referencing values from different resources you do so with the key, such as azurerm_resource_group.this["rg_one"].id and azurerm_resource_group.this["rg_two"].id which reference the Azure resource ids for two different resource groups deployed by the same resource block.

Not only does understanding the keys help with looping and references, it also flows into how the resources can be organised in the state file - which is also something for another time.

Wrapping up & what’s next?
#

We’ve covered quite a lot in this post, nice work for sticking with it. We’re learned about primitive and complex data types and what the shape of them looks like.

We’ve learned that although list, set, and tuple types all use square brackets [] it doesn’t mean they’re the same thing, and each type has it’s own behaviours; the same goes for object and map with {}.

We’re gone over explicit/strong typing and how we can declare types on Terraform variable blocks, or use the type conversion functions within locals blocks to be more explicit. We touched on the B-side of this, being implicit/inferred typing where Terraform will make some assumptions on values created in locals, and also when it performs automatic type conversion behind the scenes.

We’ve learned how to refer to specific values/keys/indexes using syntax or the element() and lookup() functions, along with joining types together with the merge(), concat(), and setunion() Terraform language functions.

In data types part 2 we’ll dig a little deeper into type constraints and conversions. Until next time, stay curious.

Terraform and Azure - This article is part of a series.
Part 3: This Article