Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.15/NEW FEATURES-20260212-181401.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: NEW FEATURES
body: '`convert` function, which allows for precise inline type conversions'
time: 2026-02-12T18:14:01.356478-05:00
custom:
Issue: "38160"
11 changes: 11 additions & 0 deletions internal/addrs/parse_ref.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,17 @@ func parseRef(traversal hcl.Traversal) (*Reference, tfdiags.Diagnostics) {
}
remain := traversal[1:] // trim off "action"
return parseActionRef(rootRange, remain)

case "string", "number", "bool", "any":
if len(traversal) == 1 {
// A standalone word is a primitive type constraint.
return nil, diags
}

// There could technically be providers that implement resources by
// these names, so if the traversal has more parts we still fallthrough
// to the default resource parsing.
fallthrough
default:
return parseResourceRef(ManagedResourceMode, rootRange, traversal)
}
Expand Down
41 changes: 41 additions & 0 deletions internal/addrs/parse_ref_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,47 @@ func TestParseRef(t *testing.T) {
},
``,
},

// a standalone primitive name is a type constraint, and does not
// reference anything
{
`string`,
nil,
``,
},
{
`bool`,
nil,
``,
},
{
`number`,
nil,
``,
},
{
`any`,
nil,
``,
},

{
// a multi-step traversal starting with a primitive name is still
// treated as any other resource reference
`string.foo`,
&Reference{
Subject: Resource{
Mode: ManagedResourceMode,
Type: "string",
Name: "foo",
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 11, Byte: 10},
},
},
``,
},
}

for _, test := range tests {
Expand Down
34 changes: 30 additions & 4 deletions internal/command/jsonfunction/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,16 @@ func Marshal(f map[string]function.Function) ([]byte, tfdiags.Diagnostics) {
signatures := newFunctions()

for name, v := range f {
if name == "can" || name == "core::can" {
switch name {
case "can", "core::can":
signatures.Signatures[name] = marshalCan(v)
} else if name == "try" || name == "core::try" {
case "try", "core::try":
signatures.Signatures[name] = marshalTry(v)
} else if name == "templatestring" || name == "core::templatestring" {
case "templatestring", "core::templatestring":
signatures.Signatures[name] = marshalTemplatestring(v)
} else {
case "convert", "core::convert":
signatures.Signatures[name] = marshalConvert(v)
default:
signature, err := marshalFunction(v)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
Expand Down Expand Up @@ -179,6 +182,29 @@ func marshalTry(try function.Function) *FunctionSignature {
}
}

// marshalConvert returns a static function signature for the try function.
// We need this exception because the function implementation uses capsule
// types that we can't marshal.
func marshalConvert(convert function.Function) *FunctionSignature {
return &FunctionSignature{
Description: convert.Description(),
ReturnType: cty.DynamicPseudoType,
Parameters: []*parameter{
{
Name: convert.Params()[0].Name,
Description: convert.Params()[0].Description,
IsNullable: convert.Params()[0].AllowNull,
Type: cty.DynamicPseudoType,
},
{
Name: convert.Params()[1].Name,
Description: convert.Params()[1].Description,
Type: cty.DynamicPseudoType,
},
},
}
}

// marshalCan returns a static function signature for the can function.
// We need this exception because the function implementation uses capsule
// types that we can't marshal.
Expand Down
92 changes: 92 additions & 0 deletions internal/lang/funcs/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
package funcs

import (
"fmt"
"reflect"
"strconv"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/customdecode"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/hashicorp/terraform/internal/lang/ephemeral"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/lang/types"
Expand Down Expand Up @@ -158,3 +163,90 @@ var TypeFunc = function.New(&function.Spec{
func Type(input []cty.Value) (cty.Value, error) {
return TypeFunc.Call(input)
}

// ConvertFunc is a cty function which takes any value as the first argument,
// and returns the result of converting the first argument to the type
// constraint literal given as the second argument. We allow type constraint
// literals by injecting a custom decoder into HCL using a cty capsule type.
var ConvertFunc = makeConvertFunc()

// makeConvertFunc is a constructor function because of the unusual method we
// have for passing a custom decoder into HCL. We need to be able to declare a
// recursive closure that can return the same value that it's assigned to, hence
// there needs some procedural code to construct it.
func makeConvertFunc() function.Function {
// We want to be able to use optional and default values in our type
// constrains, so we need to be able to track both the type and the default
// values.
type typeConstraintArg struct {
Type cty.Type
Defaults *typeexpr.Defaults
}

var typeConstraintType cty.Type
typeConstraintType = cty.CapsuleWithOps("type_constraint", reflect.TypeFor[typeConstraintArg](), &cty.CapsuleOps{
ExtensionData: func(key any) any {
switch key {
// HCL will look for a capsule with CustomExpressionDecoder when
// decoding function arguments, and then insert this decoder
// allowing us to use our standard type expression syntax.
case customdecode.CustomExpressionDecoder:
return customdecode.CustomExpressionDecoderFunc(
func(expr hcl.Expression, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) {
ty, defs, diags := typeexpr.TypeConstraintWithDefaults(expr)
if diags.HasErrors() {
return cty.NilVal, diags
}
return cty.CapsuleVal(typeConstraintType, &typeConstraintArg{Type: ty, Defaults: defs}), nil
},
)
default:
return nil
}
},
TypeGoString: func(_ reflect.Type) string {
return "typeConstraint"
},
GoString: func(raw any) string {
tyPtr := raw.(*typeConstraintArg)
// The GoString value from our constraint will suffice here.
return fmt.Sprintf("typeConstraint(%#v)", tyPtr.Type)
},
})

return function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "value",
Type: cty.DynamicPseudoType,
AllowNull: true,
AllowDynamicType: true,
},
{
Name: "type",
Type: typeConstraintType,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
constraint := args[1].EncapsulatedValue().(*typeConstraintArg)
// optional attributes are only used during the conversion process,
// the final type must be fully defined.
return constraint.Type.WithoutOptionalAttributesDeep(), nil
},
Impl: func(args []cty.Value, _ cty.Type) (cty.Value, error) {
// the retType parameter tells us the final type, but it does not
// contain optional attributes or defaults, so we need to extract
// our typeConstraintArg from the arguments again.
constraint := args[1].EncapsulatedValue().(*typeConstraintArg)
v, err := convert.Convert(args[0], constraint.Type)
if err != nil {
return cty.NilVal, function.NewArgError(0, err)
}
if constraint.Defaults != nil {
v = constraint.Defaults.Apply(v)
}

return v, nil
},
})
}
4 changes: 4 additions & 0 deletions internal/lang/funcs/descriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ var DescriptionList = map[string]descriptionEntry{
Description: "`contains` determines whether a given list or set contains a given single value as one of its elements.",
ParamDescription: []string{"", ""},
},
"convert": {
Description: "`convert` converts a value to the given type constraint.",
ParamDescription: []string{"", ""},
},
"csvdecode": {
Description: "`csvdecode` decodes a string containing CSV-formatted data and produces a list of maps representing that data.",
ParamDescription: []string{""},
Expand Down
Loading