Skip to content

inline type conversions #38160

Open
jbardin wants to merge 5 commits intomainfrom
jbardin/inline-type-conversion
Open

inline type conversions #38160
jbardin wants to merge 5 commits intomainfrom
jbardin/inline-type-conversion

Conversation

@jbardin
Copy link
Member

@jbardin jbardin commented Feb 12, 2026

This PR adds a convert function, allowing for inline type conversions within Terraform configuration. The full type constraint syntax is available within the second function argument, allowing for explicit typing of values, and injection of optional attributes with defaults.

Most of the time Terraform's type inference is sufficient when assigning data in configuration, but there are still numerous edge cases which cause problems for users when the inferred types don't align correctly. These for example may arise from the opposing sides of conditional expressions having different inferred types, a lack of a literal syntax for expressing certain values (we don't have syntax for maps, lists, or sets!), or equality comparisons where type is not implicitly converted.

The convert function more easily takes the default literal types, and directly applies the type the user specifies:

// user wants a set of lists
convert([[1, 2, 3]], set(list(number)))

The conversion works in the same manner that assignment to a typed variable input works, without the need for an additional module just to complete the type conversion. This means that defaults can be injected for object types:

convert({test = {}}, map(object({
  attr = optional(string)
  with_default = optional(string, "default")
})))

// results in
tomap({
  "test" = {
    "attr" = tostring(null)
    "with_default" = "default"
  }
})

This also lends itself to data manipulation using the conversion, or even prevents conversions which may not be wanted:

// given this initial structure of local.foo
[
  {
    "more" = "b"
    "test" = [
      "a", "a", "b",
    ]
  },
  {
    "test" = toset([
      "a",
    ])
  },
]

// the user may need to have a list of identical objects, while also avoiding 
// the conversion of "test" to a `set` which would drop duplicates.

[ for obj in local.foo: convert(obj, object({more=optional(string, "default"),test=list(string)})) ]
 
 // results in
 [
  {
    "more" = "b"
    "test" = tolist([
      "a",
      "a",
      "b",
    ])
  },
  {
    "more" = "default"
    "test" = tolist([
      "a",
    ])
  },
]

Another common case is where a user wants to instantiate an empty container containing some more complex type. This leads to problems like:{} isn't an empty map, which would need an element type, it's an empty object which can never have attributes. Or [] which isn't an empty list without an element type, but rather an empty tuple which has exactly zero elements.

Convert allows for the explicit creation of these zero values for users

convert({}, map(string))
convert(null, list(number))
convert([], set(map(string)))

This also replaces the need for most of the existing to* conversion functions for complex types, which often lack the expressivity to fully solve user issues. They can be retained as is for now though, since they would still be a valid shorthand for when the type conversion is only concerned with the outer container type, and there is no need to force users to change syntax since the identifiers are otherwise of no value, This would be the equivalent convert syntax for these functions

tostring(v) => convert(v, string)
tonumber(v) => convert(v, number)
tobool(v)   => convert(v, bool)
toset(v)    => convert(v, set(any))
tolist(v)   => convert(v, list(any))
tomap(v)    => convert(v, map(any))

Compatibility concerns:

Prior to this change, because of the open namespace for provider resources, the identifiers string, number, bool, and any would be considered the start of a resource reference when resolving variables in an expression. These identifiers were not reserved, but we can at least partly consider the context in which they are resolved. Because referring to a resource type without a resource name is not valid, if any of these words appear as a single element traversal (i.e. no .name*), we can choose to not resolve them as a reference in order to allow them to appear in the type expression.

This doesn't break any existing behavior, however it does change an error slightly in the case one of these identifiers is accidentally used. In older versions, something like attr = number would result in the error A reference to a resource type must be followed by at least one attribute access, specifying the resource name. Now the error reads like There is no variable named "number", which might arguably be easier to understand in the case where a resource called number doesn't exist. If the resource is prefaced with the resource. identifier the error does not change.

In the rare case that there is a resource using one of these type names, and there is an an error in the configuration where the type is not followed by the name, the current error is a slight downgrade. That may be acceptable given the near zero chance of it happening, but a possible mitigation would be to extend the There is no variable named error with another line that that states that if it is a known resource type, to follow it with a resource name.


While we're in here we also remove the duplicate mapping of functions in the lang package, which has led to functions missing from the test framework multiple times.


We can use this feature as a milestone to close the following issues:
Closes #23562
Closes #27643
Closes #29622
Closes #29809
Closes #32304
Closes #33303
Closes #35027

Most of those are not directly solved by this feature, but rather this feature offers a way to help those cases in various ways, while still working within the language and type system that we have. More information can be added in the individual issues on a case by case basis.

@jbardin jbardin marked this pull request as ready for review February 12, 2026 23:24
@jbardin jbardin requested a review from a team as a code owner February 12, 2026 23:24
mildwonkey
mildwonkey previously approved these changes Feb 13, 2026
Copy link
Contributor

@mildwonkey mildwonkey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, and reading the linked issues was interesting. this is going to be very useful!

thank you for this trip down memory lane 😂 - my very first every terraform task was porting funcs to go-cty

The convert function allows for inline type conversions within the
Terraform language. A custom decoder for the function which allows for
type expression literals as the second parameter.
We add `convert` to our set of functions, and we clean up the duplicate
function assignments in this package. The multiple assignments have
meant that any function changes are inevitably forgotten from the testing
framework, and we shouldn't need to declare the mapping multiple times.
In order to allow for type constraints to be inserted into arbitrary
expressions using the `convert` function, we need to allow for the use
of primitive type names. Previously `string`, `number`, `bool`, and
`any` would be considered as incomplete resource references, because
there are no constraints on what providers can name resources. However
because you cannot use a standalone resource type name as a reference in
HCL, we can make an exception here for when these identifiers are not
part of a longer traversal.
@jbardin jbardin force-pushed the jbardin/inline-type-conversion branch from 808ee8a to c507cfe Compare February 13, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants