refactor: open enums with strict client - #2759
Conversation
Republished with strict date parsing re-enabled, so unlike rc.3 this does not require the RFCDate->Date migration -- only the OpenEnum/discriminated-union/control-flow fallout from lax mode. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…itches Lax mode widens PaymentPeriod and GarnishmentType from closed enums to OpenEnum (adds Unrecognized<string>), so the exhaustive switch in each label helper is no longer exhaustive and falls through without a return. Add a default branch that returns the raw value, matching the same unrecognized-value fallback already used for RecoveryCasesList. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rd and form
Lax mode's forwardCompatibleUnionsByDefault: tagged-only resolves an
unparseable W4 shape to { w4DataType: "UNKNOWN", raw, isUnknown: true }
instead of one of the two known variants, so code assuming a known
shape and reading a variant-specific field directly now fails to
compile. Guard both sites using each file's existing narrowing idiom
for this union rather than introducing a new one:
- FederalTaxesCard: filingStatus was the one field read without the
'x' in federalTaxes check already used for the others.
- useFederalTaxesForm: the submit handler's "not loaded" guard now also
covers "loaded but unparseable" (no version field to submit against).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wing The lax-mode API client widens previously-closed enums to OpenEnum<T> = T | Unrecognized<string> so an unrecognized member doesn't crash response parsing. Plain `===`/property-existence checks don't narrow away the Unrecognized branch, so call sites need an explicit predicate against the enum's own literal members. Not yet used anywhere -- follow-up commits migrate call sites onto it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ishment narrowing Two pre-existing predicates narrowed a value against the wrong enum (a differently-scoped destination/request type) instead of its actual source enum. Once the source enum gained members the destination enum didn't share, this silently treated a real, known value as unrecognized: - PayrollConfiguration.tsx / PayrollEditEmployee.tsx: paymentMethod dropped 'Historical' on submit. - useDeductionForm.tsx: garnishmentType dropped 'child_support' when resolving the fetched deduction's default. Both now narrow against the full source enum and exclude the specific value as an explicit, separate step. The Payroll files also add the deductions[].amountType narrowing the same submit payload needs to type-check under the OpenEnum client -- left in this commit rather than split further since it's in the same touched region. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…helper Mechanical pass closing out the rest of the OpenEnum-narrowing tsc errors from the lax-mode client migration (SDK-1307) -- 24 files across Company/Contractor/Employee/Payroll, each swapping a local, ad-hoc isKnownX check (or an unguarded access that now needs one) for isKnownEnumValue/toKnownEnumValue. No known-value narrowing bugs in this batch (unlike the paymentMethod/garnishmentType fixes landed separately) -- these are all either new guards or straightforward like-for-like replacements. TimeOffFlowComponents.tsx is the one exception: it's in the same bucket (an OpenEnum value needing narrowing) but reuses the existing, unrelated isEditableTimeOffPolicyType predicate from timeOffPolicyTypes.ts rather than the new shared helper -- included here since it's the same class of fix, not because it depends on openEnum.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| taxPayerType: toKnownEnumValue(payload.taxPayerType, TaxPayerType, undefined), | ||
| filingForm: toKnownEnumValue(payload.filingForm, FilingForm, undefined), |
There was a problem hiding this comment.
One common category of change: when building the payload to submit a form, don't just send back whatever we got from the API. If we recognize the value as part of the enum, send that; otherwise, send undefined.
This means we never send an unrecognized value back to the server, even if we can handle receiving unrecognized values with a bit more grace
| title: isKnownEnumValue(stepId, Id) | ||
| ? t(`stepTitles.${stepId}`) | ||
| : (step.title ?? stepId), | ||
| description: isKnownEnumValue(stepId, Id) ? t(`stepDescriptions.${stepId}`) : '', |
There was a problem hiding this comment.
Another common category: before assuming that the value from the API maps to a valid translation key, check the enum. If we can't map it to a valid key, display the raw value from the server or an empty string depending on context
| function getSetupStatus(req: TaxRequirementStatesList): ClosedEnum<typeof SetupStatus> { | ||
| return toKnownEnumValue(req.setupStatus, SetupStatus, SetupStatus.InProgress) |
There was a problem hiding this comment.
I don't think this is the right behavior -- as written, if we get back a setup status we don't recognize, we just use InProgress instead.
This should probably instead have some kind of error for "there is a value, I don't know what it is, so you cannot proceed with setting up your state taxes" treatment
| const resolvedDefaults: ContractorPayFormData = useMemo( | ||
| () => ({ | ||
| wageType: contractor?.wageType ?? WageType.Fixed, | ||
| wageType: toKnownEnumValue(contractor?.wageType, WageType, WageType.Fixed), |
There was a problem hiding this comment.
This feels like a reasonable override. When building the default values, try the contractor?.wageType first but if that's an enum value we don't recognize, override it with Fixed
| default: | ||
| return value |
There was a problem hiding this comment.
Honestly we shouldn't have ever had these kind of case statements which fall through and do nothing; it's technically type safe but based on values we don't control end-to-end
|
|
||
| const resolvedFetchedGarnishmentType = | ||
| isKnownEnumValue(fetchedDeduction?.garnishmentType, GarnishmentType) && | ||
| fetchedDeduction.garnishmentType !== GarnishmentType.ChildSupport |
There was a problem hiding this comment.
I think this should have been written to use GARNISHMENT_TYPES const from above instead of excluding ChildSupport manually
| updateGarnishmentRequest: { | ||
| ...payload, | ||
| totalAmount: payload.totalAmount ?? undefined, | ||
| active: false, | ||
| version: payload.version as string, | ||
| garnishmentType: toKnownEnumValue(payload.garnishmentType, GarnishmentType, undefined), | ||
| }, |
There was a problem hiding this comment.
Any place we're passing a payload to a change request, that we got directly from a response payload, we need to strip out unknown enum values since in this model we accept getting values we don't know about but we will never send them and the updated typescript from the client enforces this
| onboardingStatus={toKnownEnumValue( | ||
| employee.onboardingStatus, | ||
| EmployeeOnboardingStatus1, | ||
| undefined, | ||
| )} |
There was a problem hiding this comment.
I think this is another place where instead of falling back on undefined we may want to handle an unknown onboarding status with some kind of standard treatment
Summary
This is a draft specifically so the team can evaluate the open-enum narrowing strategy before it's adopted repo-wide. The relevant commits are split out for that purpose:
ccfbf187-- addsisKnownEnumValue/toKnownEnumValue(src/helpers/openEnum.ts), the mechanism itself, unused until the next commits. This is the one to scrutinize.2d1cc383-- two live behavior bugs the mechanism fixes (payment method silently dropped'Historical', garnishment silently dropped'child_support'') -- worth keeping regardless of which narrowing mechanism wins.65e375ed-- mechanical migration of the remaining ~25 call sites onto the helper.d6b7fb37,1dc6dc33-- unrelated small fixes for non-exhaustive switches and a discriminated-union unknown-variant guard, needed either way.eecfb3dc-- the rc.4 package bump itself.Test plan
tsc --noEmitclean (0 errors) on this branch against rc.4PayrollConfiguration.test.tsx's gross-up test, passes in isolation -- known flake unrelated to this change)eslintclean on all touched filesopenEnum.tsnarrowing approach before this ships🤖 Generated with Claude Code