skip to content

A Terraform module declares `variable "tags" { type = map(any) }` and a caller passes `{ Name = "web", Retention = 30 }`. What does the module actually receive, and why is `map(any)` a poor interface?

level: seniorimportance: should knowfreq 38%

answer

  1. any is a wildcard, not a union
  2. one element type must win
  3. numbers quietly become strings
  4. no common type means plan error
  5. bare any does not unify

basics

~20 s

Terraform unifies a collection's element type to one concrete type, so the module receives a map of strings where Retention is "30". If no single element type fits — one value being a list, say — the whole plan fails instead. map(any) neither preserves mixed types nor checks anything.

solid answer

~50 s

`any` inside a collection is not "each element may differ" — it is a placeholder Terraform must resolve to one concrete element type, because `map(T)` requires every element to share `T`. Given `{ Name = "web", Retention = 30 }` it unifies to `map(string)`: the number `30` converts safely to `"30"` and the module sees a string. Pass `{ Name = "web", Ports = [80, 443] }` instead and there is no type both values convert to, so the plan aborts with a conversion error. That is the worst of both worlds — the caller gets silent coercion in one case and a confusing failure in the other, with no declared interface either way. Write `map(string)` if that is what you mean, and an `object({...})` with `optional()` attributes if the value really is a mixed-shape structure. Bare `any` at the top level does preserve mixed types, but it gives up all plan-time checking.

code

hcl · 13 lines
hcl
variable "unified" {
  type    = map(any)
  default = { Name = "web", Retention = 30 }
}

variable "not_unified" {
  type    = any
  default = { Name = "web", Ports = [80, 443] }
}

output "retention_type" {
  value = can(var.unified["Retention"] + 1) # false: it is now a string
}

go deeper

for a junior

Know that any means no type is declared, and that leaving type off a variable is the same thing. Prefer writing the real type when you know it.

for a middle

Explain unification: inside a collection, any must resolve to one element type, so a number alongside a string becomes a string and an unconvertible mix fails the plan.

for a senior

Diagnose it in a real repo — a numeric input arriving as a string, or a caller's valid-looking value rejected — and trace both back to the element-type unification the declaration invited.

for a principal

Treat untyped inputs as an interface debt: they push failures from the caller's plan into the module's internals, so decide deliberately where an opaque pass-through value is worth the loss of checking and documentation.

## `any` is a placeholder, not a union The single most common misreading of Terraform's type system is that `any` means "anything, element by element". It does not. `any` is a *wildcard to be resolved*: wherever it appears inside a collection type, Terraform must pick one concrete type for that position before the value can exist, because `list(T)`, `set(T)` and `map(T)` are defined as collections whose elements all share `T`. So `map(any)` does not mean "a map with values of assorted types". It means "a map whose element type I have not told you — work it out". ## What unification does to a real value Given `{ Name = "web", Retention = 30 }`, Terraform looks for a single type that every element can convert to. A number converts to a string safely; a string does not convert to a number safely. So the unified element type is `string`, and inside the module: ```hcl var.tags["Retention"] == "30" # a string, not a number ``` Nothing warns you. If the module does arithmetic on that value it will need an explicit conversion; if it passes it to a resource argument expecting a number, the conversion happens again on the way out and probably works — which is why this bug survives for a long time in tag maps, where everything ends up as a string anyway. Now change the value to `{ Name = "web", Ports = [80, 443] }`. There is no type both a string and a tuple convert to, so unification fails and the plan aborts with a conversion error pointing at the variable. The caller's mental model — "it says any, so anything goes" — has just produced a failure they cannot explain from the module's declaration. ## The same trap in `list(any)` and in defaults `list(any)` behaves identically: `["a", 1]` becomes `["a", "1"]`. It also bites through *defaults* in object types and through `merge`, where a map built from mixed sources gets unified at the point it is fed into a typed variable. The rule to carry is: any time a value crosses into a collection type whose element type is `any`, expect one type to win. ## Bare `any` is different A variable declared with a plain `type = any` (or with `type` omitted altogether, which is the same thing) does **not** unify, because there is no collection forcing a common element type. `{ Name = "web", Ports = [80, 443] }` passes through as an object with two differently typed attributes, untouched. That is genuinely useful in a narrow case: a value the module only forwards and never inspects, such as an arbitrary JSON-shaped document handed straight to a provider argument. What you give up is everything the type system was for. There is no plan-time check, so a caller's mistake surfaces as an obscure error inside a resource — or, worse, as a successful apply of the wrong thing. There is no self-documentation either: a reader of the module's interface learns nothing about what to pass, and generated module documentation has nothing to show. ## What to write instead - If the values really are all strings, say `map(string)`. It is honest, it converts numbers for the caller's convenience, and it rejects a nested list clearly. - If the structure is rich and partly optional, use `object({...})` with `optional()` attributes. That is the case `any` is usually a lazy substitute for. - If the module forwards an opaque document it never reads, `any` is defensible — write a description saying exactly that, so the absence of a type is a decision rather than an omission. ## Why interviewers ask this Because it separates people who have read the type documentation from people who have pattern-matched `map(any)` out of an old module. The visible symptoms — a number that became a string, or a plan that refuses a value the type appears to permit — are both cases where the tool did exactly what it documents and nobody expected it. Recognising the unification step is what turns both into a one-line diagnosis.

  • Why does map(any) accept a mixed string-and-number value but reject a mixed string-and-list one?
    Because unification looks for one type every element can convert to. A number converts safely to a string, so `string` wins and the number is coerced. A tuple has no safe conversion to a string, so no common element type exists and the conversion fails. The rule is about the existence of a common type, not about how different the values look.
  • When is a bare type = any variable actually the right choice?
    When the module forwards the value without inspecting it — an opaque policy or configuration document handed straight to a provider argument whose shape the module has no opinion about. Even then, write a description that says so explicitly, so the missing constraint reads as a decision rather than an oversight.
  • How would you diagnose a module where a numeric input mysteriously arrives as a string?
    Look at the variable's declared type first. If it is `map(any)` or `list(any)`, unification is the likely cause: another element in the same collection is a string, so string won as the element type. Confirm by testing the value with `can(x + 0)` or by tightening the constraint to the type you actually expect and seeing what the plan rejects.

saying these in an interview costs you the question

  • Reading map(any) as a map with mixed value types
  • Expecting an error rather than silent coercion of the number
  • Thinking any is checked at apply rather than not checked at all
  • Assuming bare any and map(any) behave the same way
  • Using any because writing an object type is tedious

context