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?
answer
- any is a wildcard, not a union
- one element type must win
- numbers quietly become strings
- no common type means plan error
- bare any does not unify
basics
~20 sTerraform 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 linesvariable "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
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.
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.
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.
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