Skip to content

Commit 89f0621

Browse files
convert: More forthcoming about cause in MismatchMessage
Due to some early experience with this function initially exposing too much implementation detail that tended to cause readers to get fixated on the wrong parts of the error message, convert.MismatchMessage switched to a mode where in some cases it withholds information about what type was given to a failed type conversion. However, this moved it to the opposite extreme of being _too_ cautious and not reporting some information that _is_ more helpful than confusing. After referring back to my earlier notes on what tended to cause confusion, this now makes a more thorough effort to classify various "unlikely to cause confusion" combinations of got and want type and in those cases it will now report what type was given alongside what value was required. This is a subjective boundary and so might get negotiated further in future, but based on the existing tests and some further manual testing this seems to make a relatively good compromise where it generates useful information in most cases where the direct type is the most likely problem, while still avoiding saying confusing things that expose implementation details like untyped nulls, empty collections with as-yet-unknown element types, etc. This work was sponsored by Spacelift as an indirect contribution to the OpenTofu project. Thanks!
1 parent 67d85b6 commit 89f0621

File tree

6 files changed

+69
-13
lines changed

6 files changed

+69
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# 1.16.3 (Unreleased)
22

3+
* `convert`: Now generates more specific error messages in various cases of type conversion failure, giving additional information about the type that was given as compared to the type that was wanted by the caller.
34

45
# 1.16.2 (January 21, 2025)
56

cty/convert/conversion_capsule_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,12 @@ func TestConvertCapsuleType(t *testing.T) {
7979
{
8080
From: cty.True,
8181
To: capTy,
82-
WantErr: `test thingy required`,
82+
WantErr: `test thingy required, but have bool`,
8383
},
8484
{
8585
From: capVal("hello"),
8686
To: cty.Bool,
87-
WantErr: `bool required`,
87+
WantErr: `bool required, but have test thingy`,
8888
},
8989
{
9090
From: cty.UnknownVal(capTy),
@@ -99,22 +99,22 @@ func TestConvertCapsuleType(t *testing.T) {
9999
{
100100
From: cty.UnknownVal(cty.Bool),
101101
To: capTy,
102-
WantErr: `test thingy required`,
102+
WantErr: `test thingy required, but have bool`,
103103
},
104104
{
105105
From: cty.NullVal(cty.Bool),
106106
To: capTy,
107-
WantErr: `test thingy required`,
107+
WantErr: `test thingy required, but have bool`,
108108
},
109109
{
110110
From: cty.UnknownVal(capTy),
111111
To: cty.Bool,
112-
WantErr: `bool required`,
112+
WantErr: `bool required, but have test thingy`,
113113
},
114114
{
115115
From: cty.NullVal(capTy),
116116
To: cty.Bool,
117-
WantErr: `bool required`,
117+
WantErr: `bool required, but have test thingy`,
118118
},
119119
{
120120
From: capIntVal(42),

cty/convert/mismatch_msg.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func MismatchMessage(got, want cty.Type) string {
5353
case got.IsCollectionType() && want.IsCollectionType():
5454
return mismatchMessageCollectionsFromCollections(got, want)
5555

56+
case !typesAreLikelyToCauseConfusion(got, want):
57+
return fmt.Sprintf("%s required, but have %s", want.FriendlyName(), got.FriendlyName())
58+
5659
default:
5760
// If we have nothing better to say, we'll just state what was required.
5861
return want.FriendlyNameForConstraint() + " required"
@@ -224,3 +227,55 @@ func mismatchMessageCollectionsFromCollections(got, want cty.Type) string {
224227
}
225228
return fmt.Sprintf("incorrect %s: %s", noun, MismatchMessage(gotEty, wantEty))
226229
}
230+
231+
func typesAreLikelyToCauseConfusion(got, want cty.Type) bool {
232+
// NOTE: This function is intended to be used as the penultemate test
233+
// in MismatchMessage, and so it intentionally does not address
234+
// combinations that are already addressed by the more specific
235+
// earlier tests.
236+
if gotP, wantP := got.IsPrimitiveType(), want.IsPrimitiveType(); gotP != wantP {
237+
// There's never any situation where a primitive type could successfully
238+
// convert to a non-primitive one, so this is very unlikely to be
239+
// intended as an automatic type conversion. This case is doing most
240+
// of the useful work of this function, allowing us to report such
241+
// things as "string required, but got tuple".
242+
return false
243+
}
244+
if want == cty.String {
245+
// All of the primitive types can always successfully convert to
246+
// string, regardless of value.
247+
return false
248+
}
249+
if (want == cty.Bool && got == cty.Number) || (got == cty.Bool && want == cty.Number) {
250+
// There are no automatic conversions between bool and number, so
251+
// describing both directions of this is unlikely to cause confusion.
252+
return false
253+
}
254+
if got.IsCollectionType() && want.IsCollectionType() && !got.ElementType().HasDynamicTypes() && want.ElementType().HasDynamicTypes() {
255+
// Describing mismatches of collection element types is helpful
256+
// when we have specific types to report, but confusing when
257+
// there are any inexact types involved because readers tend to
258+
// then think the inexactness of the element type is the problem,
259+
// when it's far more likely to be the collection type kind that's
260+
// causing the problem.
261+
return false
262+
}
263+
if got.IsTupleType() && want.IsObjectType() || got.IsObjectType() && got.IsTupleType() {
264+
// The structural type kinds get described just by their bare names,
265+
// "tuple" or "object", and so they are not confusing when they
266+
// differ. They _are_ potentially confusing when we have matching kinds
267+
// but different element types within, but thankfully that's most
268+
// often handled by one of the earlier cases in MismatchMessage.
269+
return false
270+
}
271+
// We are intentionally a little more vague for string-to-number and
272+
// string-to-bool conversions because the rules for those are a little
273+
// too subtle to be described purely as "need X but want Y", and historical
274+
// experience shows that describing it that way causes folks to focus
275+
// on the wrong problem (their value is wrong, not their type).
276+
277+
// If we find that we're getting here in additional specific cases where
278+
// it would be more helpful than confusing to report both types then
279+
// we'll add additional rules here in a future version.
280+
return true
281+
}

cty/convert/mismatch_msg_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func TestMismatchMessage(t *testing.T) {
1515
{
1616
cty.Bool,
1717
cty.Number,
18-
`number required`,
18+
`number required, but have bool`,
1919
},
2020
{
2121
cty.EmptyObject,
@@ -55,7 +55,7 @@ func TestMismatchMessage(t *testing.T) {
5555
cty.List(cty.Object(map[string]cty.Type{
5656
"foo": cty.String,
5757
})),
58-
`incorrect list element type: object required`,
58+
`incorrect list element type: object required, but have string`,
5959
},
6060
{
6161
cty.List(cty.EmptyObject),

cty/convert/public_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ func TestConvert(t *testing.T) {
623623
Type: cty.Object(map[string]cty.Type{
624624
"foo": cty.Number,
625625
}),
626-
WantError: `attribute "foo": number required`,
626+
WantError: `attribute "foo": number required, but have bool`,
627627
},
628628
{
629629
Value: cty.ObjectVal(map[string]cty.Value{
@@ -632,7 +632,7 @@ func TestConvert(t *testing.T) {
632632
Type: cty.Object(map[string]cty.Type{
633633
"foo": cty.Number,
634634
}),
635-
WantError: `attribute "foo": number required`,
635+
WantError: `attribute "foo": number required, but have bool`,
636636
},
637637
{
638638
Value: cty.NullVal(cty.String),
@@ -1064,7 +1064,7 @@ func TestConvert(t *testing.T) {
10641064
"d": cty.String,
10651065
}),
10661066
}, []string{"b", "c"}),
1067-
WantError: `map element type is incompatible with attribute "c": object required`,
1067+
WantError: `map element type is incompatible with attribute "c": object required, but have string`,
10681068
},
10691069
{
10701070
Value: cty.TupleVal([]cty.Value{

cty/function/stdlib/format_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ func TestFormat(t *testing.T) {
340340
cty.StringVal("%d green bottles standing on the wall"),
341341
[]cty.Value{cty.True},
342342
cty.NilVal,
343-
`unsupported value for "%d" at 0: number required`,
343+
`unsupported value for "%d" at 0: number required, but have bool`,
344344
},
345345
{
346346
cty.StringVal("%d green bottles standing on the wall"),
@@ -794,7 +794,7 @@ func TestFormatList(t *testing.T) {
794794
cty.StringVal("%s"),
795795
[]cty.Value{cty.EmptyObjectVal},
796796
cty.ListValEmpty(cty.String),
797-
`error on format iteration 0: unsupported value for "%s" at 0: string required`,
797+
`error on format iteration 0: unsupported value for "%s" at 0: string required, but have object`,
798798
},
799799
12: {
800800
cty.StringVal("%v"),

0 commit comments

Comments
 (0)