Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

types: Consider changing value types zero-values to null or unknown #447

Closed
PJB3005 opened this issue Aug 11, 2022 · 3 comments · Fixed by #523
Closed

types: Consider changing value types zero-values to null or unknown #447

PJB3005 opened this issue Aug 11, 2022 · 3 comments · Fixed by #523
Assignees
Labels
breaking-change This PR introduces a breaking change or the resolution of this issue may require a breaking change. enhancement New feature or request types Issues and pull requests about our types abstraction and implementations.
Milestone

Comments

@PJB3005
Copy link

PJB3005 commented Aug 11, 2022

Apologies if this issue is a bit overzealous. I am converting our existing Terraform provider over to Plugin Framework (since the provider is only half-finished anyways) and this is some feedback I hope may be useful.

Module version

v0.10.0

Use-cases

Types like types.String use an explicit Null bool and Unknown bool value to indicate that they are not-valid values. This is problematic because a zero-initialized default is actually considered valid.

If I am writing an implementation of a resource, I may have a schema and model like so:

"id": {
	Type: types.StringType,
	Computed: true,
},
"foo": {
	Type:     types.StringType,
	Computed: true,
},
type Bar struct {
	ID  types.String `tfsdk:"id"`
	Foo types.String `tfsdk:"foo"`

And I fill it like so:

bar, err := r.p.client.CreateBar(items)

state.ID = types.String{Value: bar.ID}
// Oops, forgot to set Foo!

diags = resp.State.Set(ctx, result)

Now my foo attribute wasn't set. Because the default value of types.String is a valid, non-null empty string, that is now the default value of my attribute. If the struct were re-organized so that types.String{} is unknown, this above code would give an obvious error at apply time (since the provider gives back an unknown value, which is illegal).

This is only a problem for Computed properties, since "" is probably not the expected value in that case and Terraform will complain that the provider gave back an invalid value.

Furthermore, it is somewhat awkward that these are two fields are mutually exclusive. i.e. it is not legal to have both Null: true and Unknown: true. This is somewhat minor, however.

(Also dealing with these types in general is already really annoying, though I suppose you can't do much better in Go...)

Attempted Solutions

None

Proposal

To fix this, I would re-arrange these types like this:

const StateUnknown int = 0
const StateNull    int = 1
const StateValue   int = 2

type String struct {
	State int
	Value string 
}

// IsNull returns true if the String represents a null value.
func (s String) IsNull() bool {
	return s.State == StateNull
}

// IsUnknown returns true if the String represents a currently unknown value.
func (s String) IsUnknown() bool {
	return s.State == StateUnknown
}

// IsValue returns true if the String is not null or unknown
func (s String) IsValue() bool {
	return s.State == StateValue
}

This does make it more annoying to construct these values though. It might make sense to define helper functions like this as well:

func NewString(value string) String {
	return String{Value: value, State: StateValue}
}

func UnknownString() String {
	return String{}
}

func NullString() String {
	return String{State: StateNull}
}

Typing out these function calls would actually be shorter than the current system, but only by a few characters.

Furthermore, it opens up the door to further helper functions like this, which I've personally been defining myself in our own provider and think are very useful:

func NewStringP(value *string) String {
	if value == nil {
		return String{State: StateNull}
	}

	return String{Value: *value, State: StateValue}
}

References

None that I could find.

@PJB3005 PJB3005 added the enhancement New feature or request label Aug 11, 2022
@bflad bflad added the types Issues and pull requests about our types abstraction and implementations. label Aug 11, 2022
@bflad
Copy link
Member

bflad commented Sep 23, 2022

Hi @PJB3005 👋 Thank you for submitting this. Generally, really good ideas here.

This came up in #495 (review) as something to consider if/when we want to refactor the types package. While I'm not sure that we should necessarily default to any particular value kind (null, unknown, or known) since that could introduce its own potential for confusion, there are similar ideas we can use from your proposal to prevent provider developers from creating "invalid" values, which could involve function calls.

Essentially the proposal would be to (at least) unexport the Null, Unknown, and Value fields on the existing types value types and require value creation through functions such as:

func NullBool() Bool
func NullFloat64() Float64
func NullInt64() Int64
func NullList() List
func NullMap() Map
func NullNumber() Number
func NullObject() Object
func NullSet() Set
func NullString() String

func UnknownBool() Bool
func UnknownFloat64() Float64
func UnknownInt64() Int64
func UnknownList() List
func UnknownMap() Map
func UnknownNumber() Number
func UnknownObject() Object
func UnknownSet() Set
func UnknownString() String

func BoolValue(bool) Bool
func Float64Value(float64) Float64
func Int64Value(int64) Int64
func ListValue([]attr.Value) List
func MapValue(map[string]attr.Value) Map
func NumberValue(*big.Float) Number
func ObjectValue(struct) Object
func SetValue([]attr.Value) Set
func StringValue(string) String
// Potentially others, if they make sense.

For creating lists, maps, objects, and sets, this has the additional benefit of not requiring the inline definition of the element/attribute types, since they can be derived from the values.

To access "friendlier" Go types of the value, there could be through methods such as:

func (b Bool) BoolValue() bool
func (f Float64) Float64Value() float64 // could consider Float32Value(), etc.
func (i Int64) Int64Value() int64 // could consider IntValue(), etc.
func (n Number) BigFloatValue() *big.Float // could consider Float64Value() with errors, etc.
func (s String) StringValue() string
// refer to existing collection type methods, such as As()

These could return a zero-value of the type in the case it happens to be null or unknown. Determining whether a value is null or unknown can be done with the IsNull() bool and IsUnknown() bool interface methods that are already required on the value types.

Whether the internal implementation then becomes some sort of state field as you recommend or an even fancier generics implementation as mentioned in the link above, I think is fairly moot unless we want to somehow impose this implementation detail as a requirement for provider-defined types. I think that can be handled separately, but curious if you feel otherwise.


One particular thing to note here, which in your description is teased about here and I think is worth its own issue for tracking, is that the framework currently relies on Terraform core to return feedback that a value is unknown when writing the state, which is never allowed. The framework can (and probably should) catch this data handling issue after the provider-definied logic has finished and return its own, verbose error diagnostic that can explain the error and what to do about it for provider developers.


Thanks again!

@bflad bflad added the breaking-change This PR introduces a breaking change or the resolution of this issue may require a breaking change. label Sep 23, 2022
@bflad bflad added this to the v1.0.0 milestone Sep 23, 2022
@bflad
Copy link
Member

bflad commented Sep 23, 2022

Just to expand on this, since I'm going through other similar issues at the moment:

While I'm not sure that we should necessarily default to any particular value kind (null, unknown, or known) since that could introduce its own potential for confusion, there are similar ideas we can use from your proposal to prevent provider developers from creating "invalid" values, which could involve function calls.

There are in fact quite a few options:

  • Treat wholly unset value types (zero-value) as unknown, as suggested here, and rely on the later mentioned unknown value errors.
  • Treat wholly unset value types (zero-value) as null, which might better represent desirable provider developer logic ("I never set this because I never received a value for it"), but might cause other confusing Terraform core errors if value rules are not followed correctly.
  • Treat wholly unset value types (zero-value) as invalid and raise an error similar to the mentioned unknown value error.

Using something like a "value state" where the zero-value for the type makes it one of these is likely a good implementation choice. As you may be able to tell though, all of these have their own tradeoffs, unfortunately. Many of these errors are runtime specific, so it is a longer feedback loop. I'm not quite sure how we can avoid that.

I think I actually lean towards null here though, since it feels like it represents the best zero-value choice for the purpose of the type. I think the rules surrounding null values may be a little clearer to understand, even if Terraform core catches it after we send the response (e.g. "expected {known value}, got null" type errors) It would also prevent the need for introducing support for pointers to types types in the internal reflection code, so folks can implement null-on-unset behaviors in their logic.

@bflad bflad changed the title Consider changing layout of types to make zero values unknown types: Consider changing value types zero-values to null or unknown Sep 23, 2022
bflad added a commit that referenced this issue Sep 29, 2022
Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
In this example, the logic has created an invalid null AND unknown value:

type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data))

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data))
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan))

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state))
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely.
bflad added a commit that referenced this issue Sep 29, 2022
Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
In this example, the logic has created an invalid null AND unknown value:

type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data))

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data))
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan))

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state))
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely.
bflad added a commit that referenced this issue Oct 3, 2022
Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
In this example, the logic has created an invalid null AND unknown value:

type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data))

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data))
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan))

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state))
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely.
bflad added a commit that referenced this issue Oct 20, 2022
Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.
bflad added a commit that referenced this issue Oct 21, 2022
Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

Update CHANGELOG for #502

types: Note that valueState constants will be exported in the future so they can be used by external types

types: collection types
bflad added a commit that referenced this issue Oct 23, 2022
…nd Value fields

Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Go does not allow methods with the same name as a struct field, so a `ValueXXX()` method where XXX represents the returned type was chosen. After the `Value` struct fields are removed, there can be consideration for dropping the XXX in the method naming.

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `diags := types.ListValue(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `diags := types.MapValue(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `diags := types.ObjectValue(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `diags := types.SetValue(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 23, 2022
…nd Value fields

Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Go does not allow methods with the same name as a struct field, so a `ValueXXX()` method where XXX represents the returned type was chosen. After the `Value` struct fields are removed, there can be consideration for dropping the XXX in the method naming.

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `diags := types.ListValue(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `diags := types.MapValue(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `diags := types.ObjectValue(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `diags := types.SetValue(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 23, 2022
…nd Value fields

Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Go does not allow methods with the same name as a struct field, so a `ValueXXX()` method where XXX represents the returned type was chosen. After the `Value` struct fields are removed, there can be consideration for dropping the XXX in the method naming.

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `diags := types.ListValue(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `diags := types.MapValue(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `diags := types.ObjectValue(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `diags := types.SetValue(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 24, 2022
…nd Value fields

Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Go does not allow methods with the same name as a struct field, so a `ValueXXX()` method where XXX represents the returned type was chosen. After the `Value` struct fields are removed, there can be consideration for dropping the XXX in the method naming.

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `diags := types.ListValue(/* element type */, /* value */)` or `list := types.ListValueMust(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `m, diags := types.MapValue(/* element type */, /* value */)` or `m := types.MapValueMust(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `object, diags := types.ObjectValue(/* attribute types */, /* attribute values */)` or `object := types.ObjectValueMust(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `set, diags := types.SetValue(/* element type */, /* value */)` or `set := types.SetValueMust(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 24, 2022
…nd Value fields

Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Go does not allow methods with the same name as a struct field, so a `ValueXXX()` method where XXX represents the returned type was chosen. After the `Value` struct fields are removed, there can be consideration for dropping the XXX in the method naming.

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `diags := types.ListValue(/* element type */, /* value */)` or `list := types.ListValueMust(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `m, diags := types.MapValue(/* element type */, /* value */)` or `m := types.MapValueMust(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `object, diags := types.ObjectValue(/* attribute types */, /* attribute values */)` or `object := types.ObjectValueMust(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `set, diags := types.SetValue(/* element type */, /* value */)` or `set := types.SetValueMust(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 24, 2022
…nd Value fields (#502)

Reference: #447

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely and switch the zero-value implementation of these values to being null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Go does not allow methods with the same name as a struct field, so a `ValueXXX()` method where XXX represents the returned type was chosen. After the `Value` struct fields are removed, there can be consideration for dropping the XXX in the method naming.

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `diags := types.ListValue(/* element type */, /* value */)` or `list := types.ListValueMust(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `m, diags := types.MapValue(/* element type */, /* value */)` or `m := types.MapValueMust(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `object, diags := types.ObjectValue(/* attribute types */, /* attribute values */)` or `object := types.ObjectValueMust(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `set, diags := types.SetValue(/* element type */, /* value */)` or `set := types.SetValueMust(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
@bflad bflad modified the milestones: v1.0.0, v0.16.0 Oct 25, 2022
@bflad bflad self-assigned this Oct 25, 2022
bflad added a commit that referenced this issue Oct 25, 2022
…Value fields

Reference: #447
Reference: #502

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This removal of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. The zero-value implementations of these values is now null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|----------------------|--------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `list, diags := types.ListValue(/* element type */, /* value */)` or `list, diags := types.ListValueFrom(context.Context, /* element type */, any)` or `list := types.ListValueMust(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `m, diags := types.MapValue(/* element type */, /* value */)` or `m, diags := types.MapValueFrom(context.Context, /* element type */, any)` or `m := types.MapValueMust(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `object, diags := types.ObjectValue(/* attribute types */, /* attribute values */)` or `object, diags := types.ObjectValueFrom(context.Context, /* attribute types */, any)` or `object := types.ObjectValueMust(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `set, diags := types.SetValue(/* element type */, /* value */)` or `set, diags := types.SetValueFrom(context.Context, /* element type */, any)` or `set := types.SetValueMust(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 25, 2022
…Value fields

Reference: #447
Reference: #502

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This removal of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. The zero-value implementations of these values is now null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|--------------------|------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `list, diags := types.ListValue(/* element type */, /* value */)` or `list, diags := types.ListValueFrom(context.Context, /* element type */, any)` or `list := types.ListValueMust(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `m, diags := types.MapValue(/* element type */, /* value */)` or `m, diags := types.MapValueFrom(context.Context, /* element type */, any)` or `m := types.MapValueMust(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `object, diags := types.ObjectValue(/* attribute types */, /* attribute values */)` or `object, diags := types.ObjectValueFrom(context.Context, /* attribute types */, any)` or `object := types.ObjectValueMust(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `set, diags := types.SetValue(/* element type */, /* value */)` or `set, diags := types.SetValueFrom(context.Context, /* element type */, any)` or `set := types.SetValueMust(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
bflad added a commit that referenced this issue Oct 31, 2022
…Value fields (#523)

Reference: #447
Reference: #502

When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums.

One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework.

```go
type ThingResourceModel struct{
  Computed types.String `tfsdk:"computed"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  var data ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Unknown value: types.String{Null: false, Unknown: true, Value: ""}
    "computed": plan.Computed,
  })

  // Maybe some external API responses here, but showing hardcoded updates for
  // brevity. This will make the value invalid by enabling Null without
  // disabling Unknown.
  data.Computed.Null = true

  tflog.Trace(ctx, "Data Values", map[string]any{
    // Invalid value: types.String{Null: true, Unknown: true, Value: ""}
    "computed": data.Computed,
  })

  // The invalid value will be either null or unknown, depending on the
  // type implementation. If unknown, Terraform will error, since unknown
  // values are never allowed in state.
  resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```

Another issue is that it is possible to create collection value types that do not match their type definition. This issue is especially more likely with `types.Object` where it is possible to accidentially omit attributes. While the framework handling would eventually catch this issue when dealing with the invalid value, it can be caught sooner.

```go
// Invalid value (missing attribute and differing attribute name)
types.Object{
  AttrTypes: map[string]attr.Type{
    "one": types.StringType,
    "two": types.BoolType,
  },
  Attrs: map[string]attr.Value{
    "not_one": types.String{Value: "wrong name"},
  },
}
```

Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive.

```go
type ThingResourceModel struct{
  // let's assume this is left unconfigured (null in config and plan)
  Optional types.String `tfsdk:"optional"`
}

func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) {
  // Providers can opt to use a single variable that is updated based on an
  // external response, however that logic can be more difficult sometimes,
  // so it can be easier to split them. Showing the split way to exemplify
  // the "unset" problem.
  var plan, state ThingResourceModel

  resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

  tflog.Trace(ctx, "Plan Values", map[string]any{
    // Null value: types.String{Null: true, Unknown: false, Value: ""}
    "optional": plan.Optional,
  })

  // Maybe some external API responses here, but intentionally not
  // doing any state.Optional setting, which might happen if the
  // external response for that data was null for example.

  tflog.Trace(ctx, "State Values", map[string]any{
    // Zero-value: types.String{Null: false, Unknown: false, Value: ""}
    "optional": state.Optional,
  })

  // The state zero-value will later cause Terraform to error, such as:
  // Error: Provider produced inconsistent result after apply
  // ... expected cty.NullVal(cty.String), got cty.StringVal("")
  // Since the plan value said it would be null.
  resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
```

This removal of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. The zero-value implementations of these values is now null, instead of a known zero-value of the underlying type.

While not recommended for production usage without extensive testing, provider developers can opt for `panic()` inducing versions of collection value creations, rather than dealing with `diag.Diagnostics` everywhere.

Accessing value information after the migration can be accomplished with the following:

| Prior Value Access | New Value Access |
|--------------------|------------------|
| `(types.Bool).Value` | `(types.Bool).ValueBool()` |
| `(types.Bool).Null` | `(types.Bool).IsNull()` |
| `(types.Bool).Unknown` | `(types.Bool).IsUnknown()` |
| `(types.Float64).Value` | `(types.Float64).ValueFloat64()` |
| `(types.Float64).Null` | `(types.Float64).IsNull()` |
| `(types.Float64).Unknown` | `(types.Float64).IsUnknown()` |
| `(types.Int64).Value` | `(types.Int64).ValueInt64()` |
| `(types.Int64).Null` | `(types.Int64).IsNull()` |
| `(types.Int64).Unknown` | `(types.Int64).IsUnknown()` |
| `(types.List).Elems` | `(types.List).Elements()` or `(types.List).ElementsAs()` |
| `(types.List).ElemType` | `(types.List).ElementType()` |
| `(types.List).Null` | `(types.List).IsNull()` |
| `(types.List).Unknown` | `(types.List).IsUnknown()` |
| `(types.Map).Elems` | `(types.Map).Elements()` or `(types.Map).ElementsAs()` |
| `(types.Map).ElemType` | `(types.Map).ElementType()` |
| `(types.Map).Null` | `(types.Map).IsNull()` |
| `(types.Map).Unknown` | `(types.Map).IsUnknown()` |
| `(types.Number).Value` | `(types.Number).ValueBigFloat()` |
| `(types.Number).Null` | `(types.Number).IsNull()` |
| `(types.Number).Unknown` | `(types.Number).IsUnknown()` |
| `(types.Object).Attrs` | `(types.Object).Attributes()` or `(types.Object).As()` |
| `(types.Object).AttrTypes` | `(types.Object).AttributeTypes()` |
| `(types.Object).Null` | `(types.Object).IsNull()` |
| `(types.Object).Unknown` | `(types.Object).IsUnknown()` |
| `(types.Set).Elems` | `(types.Set).Elements()` or `(types.Set).ElementsAs()` |
| `(types.Set).ElemType` | `(types.Set).ElementType()` |
| `(types.Set).Null` | `(types.Set).IsNull()` |
| `(types.Set).Unknown` | `(types.Set).IsUnknown()` |
| `(types.String).Value` | `(types.String).ValueString()` |
| `(types.String).Null` | `(types.String).IsNull()` |
| `(types.String).Unknown` | `(types.String).IsUnknown()` |

Creating values after the migration can be accomplished with the following:

| Prior Value Creation | New Value Creation |
|----------------------|--------------------|
| `types.Bool{Value: /* value */}` | `types.BoolValue(/* value */)` |
| `types.Bool{Null: true}` | `types.BoolNull()` |
| `types.Bool{Unknown: true}` | `types.BoolUnknown()` |
| `types.Float64{Value: /* value */}` | `types.Float64Value(/* value */)` |
| `types.Float64{Null: true}` | `types.Float64Null()` |
| `types.Float64{Unknown: true}` | `types.Float64Unknown()` |
| `types.Int64{Value: /* value */}` | `types.Int64Value(/* value */)` |
| `types.Int64{Null: true}` | `types.Int64Null()` |
| `types.Int64{Unknown: true}` | `types.Int64Unknown()` |
| `types.List{ElemType: /* element type */, Elems: /* value */}` | `list, diags := types.ListValue(/* element type */, /* value */)` or `list, diags := types.ListValueFrom(context.Context, /* element type */, any)` or `list := types.ListValueMust(/* element type */, /* value */)` |
| `types.List{ElemType: /* element type */, Null: true}` | `types.ListNull(/* element type */)` |
| `types.List{ElemType: /* element type */, Unknown: true}` | `types.ListUnknown(/* element type */)` |
| `types.Map{ElemType: /* element type */, Elems: /* value */}` | `m, diags := types.MapValue(/* element type */, /* value */)` or `m, diags := types.MapValueFrom(context.Context, /* element type */, any)` or `m := types.MapValueMust(/* element type */, /* value */)` |
| `types.Map{ElemType: /* element type */, Null: true}` | `types.MapNull(/* element type */)` |
| `types.Map{ElemType: /* element type */, Unknown: true}` | `types.MapUnknown(/* element type */)` |
| `types.Number{Value: /* value */}` | `types.NumberValue(/* value */)` |
| `types.Number{Null: true}` | `types.NumberNull()` |
| `types.Number{Unknown: true}` | `types.NumberUnknown()` |
| `types.Object{AttrTypes: /* attribute types */, Attrs: /* attribute values */}` | `object, diags := types.ObjectValue(/* attribute types */, /* attribute values */)` or `object, diags := types.ObjectValueFrom(context.Context, /* attribute types */, any)` or `object := types.ObjectValueMust(/* attribute types */, /* attribute values */)` |
| `types.Object{AttrTypes: /* attribute types */, Null: true}` | `types.ObjectNull(/* attribute types */)` |
| `types.Object{AttrTypes: /* attribute types */, Unknown: true}` | `types.ObjectUnknown(/* attribute types */)` |
| `types.Set{ElemType: /* element type */, Elems: /* value */}` | `set, diags := types.SetValue(/* element type */, /* value */)` or `set, diags := types.SetValueFrom(context.Context, /* element type */, any)` or `set := types.SetValueMust(/* element type */, /* value */)` |
| `types.Set{ElemType: /* element type */, Null: true}` | `types.SetNull(/* element type */)` |
| `types.Set{ElemType: /* element type */, Unknown: true}` | `types.SetUnknown(/* element type */)` |
| `types.String{Value: /* value */}` | `types.StringValue(/* value */)` |
| `types.String{Null: true}` | `types.StringNull()` |
| `types.String{Unknown: true}` | `types.StringUnknown()` |
@github-actions
Copy link

github-actions bot commented Dec 1, 2022

I'm going to lock this issue because it has been closed for 30 days ⏳. This helps our maintainers find and focus on the active issues.
If you have found a problem that seems similar to this, please open a new issue and complete the issue template so we can capture all the details necessary to investigate further.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 1, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
breaking-change This PR introduces a breaking change or the resolution of this issue may require a breaking change. enhancement New feature or request types Issues and pull requests about our types abstraction and implementations.
Projects
None yet
2 participants