From be06c2674910e9da1e7dc2095fe77f50e2848ded Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 25 Aug 2026 14:24:22 -0300 Subject: [PATCH] refactor(types): name the trait value shape featureStateToValue asserted an inline `{ value_type?: ... }` to read the type key core traits use, so the key was tied to nothing and a rename was silent. TraitValue names the shape, `in` narrows the union instead of asserting, and annotating Utils.valueToTrait as the producer means both ends of the contract share one type. float_value and 'float' are included because traits carry both (api/environments/identities/traits/models.py). Closes #8365 Co-Authored-By: Claude Opus 5 (1M context) --- frontend/common/types/responses.ts | 10 ++++ .../__tests__/featureStateToValue.test.ts | 50 +++++++++++++++++-- frontend/common/utils/featureStateToValue.ts | 12 +++-- frontend/common/utils/utils.tsx | 3 +- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 799b3da75e66..045e15506eb1 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -595,6 +595,16 @@ export type FeatureStateValue = { type: 'int' | 'unicode' | 'bool' | 'float' } +// The trait shape from the core API, which keys its type as `value_type` +// where feature states use `type`. +export type TraitValue = { + boolean_value: boolean | null + float_value?: number | null + integer_value: number | null + string_value: string | null + value_type: 'int' | 'unicode' | 'bool' | 'float' +} + export type MultivariateOption = { id: number uuid: string diff --git a/frontend/common/utils/__tests__/featureStateToValue.test.ts b/frontend/common/utils/__tests__/featureStateToValue.test.ts index 3708551480e5..edc72c8d4592 100644 --- a/frontend/common/utils/__tests__/featureStateToValue.test.ts +++ b/frontend/common/utils/__tests__/featureStateToValue.test.ts @@ -1,4 +1,5 @@ import { featureStateToValue } from 'common/utils/featureStateToValue' +import type { FlagsmithValue, TraitValue } from 'common/types/responses' describe('featureStateToValue', () => { it.each([ @@ -10,10 +11,51 @@ describe('featureStateToValue', () => { expect(featureStateToValue(nested as never)).toBe(expected) }) - it('reads value_type when present (core traits)', () => { - expect( - featureStateToValue({ integer_value: 3, value_type: 'int' } as never), - ).toBe(3) + // Typed rather than cast, so these fail to compile if TraitValue drifts. + it.each<[string, TraitValue, FlagsmithValue]>([ + [ + 'int', + { + boolean_value: null, + integer_value: 3, + string_value: null, + value_type: 'int', + }, + 3, + ], + [ + 'float', + { + boolean_value: null, + float_value: 2.5, + integer_value: null, + string_value: null, + value_type: 'float', + }, + 2.5, + ], + [ + 'bool', + { + boolean_value: false, + integer_value: null, + string_value: null, + value_type: 'bool', + }, + false, + ], + [ + 'unicode', + { + boolean_value: null, + integer_value: null, + string_value: 'power_users', + value_type: 'unicode', + }, + 'power_users', + ], + ])('reads value_type on a core trait (%s)', (_label, trait, expected) => { + expect(featureStateToValue(trait)).toBe(expected) }) it('normalises a missing int/float to null', () => { diff --git a/frontend/common/utils/featureStateToValue.ts b/frontend/common/utils/featureStateToValue.ts index 730b47e336e9..9cc57ea71435 100644 --- a/frontend/common/utils/featureStateToValue.ts +++ b/frontend/common/utils/featureStateToValue.ts @@ -1,4 +1,8 @@ -import type { FeatureStateValue, FlagsmithValue } from 'common/types/responses' +import type { + FeatureStateValue, + FlagsmithValue, + TraitValue, +} from 'common/types/responses' /** * Flattens a feature state (or core trait) value into its typed scalar. @@ -10,7 +14,7 @@ import type { FeatureStateValue, FlagsmithValue } from 'common/types/responses' * their unit tests) don't pull the Flux stores in through `utils.tsx`. */ export function featureStateToValue( - value: FlagsmithValue | FeatureStateValue | undefined, + value: FlagsmithValue | FeatureStateValue | TraitValue | undefined, ): FlagsmithValue { if (value === null || value === undefined) { return null @@ -19,9 +23,7 @@ export function featureStateToValue( return value } // `value_type` is the type key on core traits; `type` on feature states. - const type = - (value as { value_type?: FeatureStateValue['type'] }).value_type ?? - value.type + const type = 'value_type' in value ? value.value_type : value.type switch (type) { case 'bool': return value.boolean_value diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 30df0c6165ae..d6564ec2b618 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -12,6 +12,7 @@ import { ProjectFlag, SegmentCondition, Tag, + TraitValue, UserPermissions, } from 'common/types/responses' import flagsmith from '@flagsmith/flagsmith' @@ -883,7 +884,7 @@ const Utils = Object.assign({}, BaseUtils, { type: 'unicode', } }, - valueToTrait(value: FlagsmithValue) { + valueToTrait(value: FlagsmithValue): TraitValue { const val = Utils.getTypedValue(value) if (typeof val === 'boolean') {