Skip to content

Refactor DeviceAttribute generics to eliminate "as T" assertions - #110

Open
heavyrubberslave wants to merge 4 commits into
mainfrom
chore/device-attribute-generics
Open

Refactor DeviceAttribute generics to eliminate "as T" assertions#110
heavyrubberslave wants to merge 4 commits into
mainfrom
chore/device-attribute-generics

Conversation

@heavyrubberslave

@heavyrubberslaveheavyrubberslave commented Aug 9, 2026

Copy link
Copy Markdown
Member

Closes#107

Summary

DeviceAttribute<T> used a single generic T for both "what kind of value" (e.g. boolean) and "is it possibly unset" (| undefined). Because T could theoretically be instantiated narrower than the concrete kind, fromString() couldn't return a plain boolean/string/Int/Float without an unsafe as T assertion.

This implements the presence-flag solution from the issue (the stricter of the two proposed designs): DeviceAttribute<V, IsSet> splits the value kind (V, fixed per subclass) from a boolean IsSet flag, and computes the storage/getter/setter type as IsSet extends true ? V : V | undefined (AttributeStorage<V, IsSet>). fromString()/isValidValue() operate on the concrete V, which TypeScript can verify without assertions. Unlike the simpler T extends V | undefined split, this closes the gap where nothing prevented T from degenerating to exactly undefined.

Changes

  • deviceAttribute.ts – base class takes <V, IsSet> now; added BaseAttributeValue (concrete kinds, no undefined) and AttributeStorage<V, IsSet>; AttributeValue keeps the same public meaning as before; hasValue() narrows to this is { value: V }
  • boolDeviceAttribute.ts, strDeviceAttribute.tsfromString/isValidValue return the concrete type directly, no assertions; Initialized* aliases now mean <true>
  • numberDeviceAttribute.ts – shared abstract base for int/float, generic over V extends Int | Float
  • intDeviceAttribute.ts, floatDeviceAttribute.tsfromString returns Int.from(num)/Float.from(num) directly
  • intRangeDeviceAttribute.ts – same, and fixes a separate pre-existing bug where parseInt()'s plain number was laundered into the branded Int type via as T; now uses Int.from(res)
  • listDeviceAttribute.ts – mechanical update to the new base signature; its two as IKey assertions remain, since the value kind is chosen by the caller per instance rather than fixed per subclass (documented in the issue as an expected, separate limitation)

Since TypeScript treats two differently-parameterized instantiations of the same generic class as mutually non-assignable once a conditional type makes a parameter measured-invariant, several consumer type declarations that always construct attributes via createInitialized() had to move from the bare (unset-only) attribute type to the Initialized* variant — this also makes their "always has a value" contract explicit at the type level:

  • EStim2bDeviceAttributes – all fields always constructed with a value in estim2bDeviceFactory.ts
  • ButtplugIoDeviceAttributes – all attributes always constructed with a value in buttplugIoDeviceFactory.ts
  • PiperVirtualDeviceAttributes.queuing, TtsVirtualDeviceAttributes.speaking/queuing/queueLength – always constructed via createInitialized()
  • Zc95DevicePatternAttributes – always constructed via createInitialized()

Zc95DevicePowerChannelAttributes keeps its bare (possibly-unset) type since those attributes are genuinely constructed unset and only get a value once a power status message is received; Zc95Device.allPowerChannelValuesDefined() narrows via an intersection type (Attr & { value: Int }) rather than the Initialized alias, since a type predicate's type must stay assignable to its original parameter type.

Tests updated accordingly (factories now use createInitialized() to match the stricter production types; one buttplug.io test goes through the untyped AnyDevice interface to still exercise the runtime undefined-guard, which stays reachable via that erasure boundary; tests/type/*.test-d.ts updated to expect the now-narrower setAttribute() types for initialized attributes).

Verification

  • npm run typecheck – passes
  • npm run lint – passes (all consistent-type-assertions/no-unsafe-type-assertion disables gone from bool/str/int/float/intRange; list's two remain as expected)
  • npm test – 449/449 tests pass

Summary by CodeRabbit

  • Improvements

    • Device attributes now clearly distinguish initialized and uninitialized states.
    • Initialized settings and readings provide defined values through the public API, reducing unnecessary undefined handling.
    • Attribute parsing and validation now return strongly typed boolean, string, integer, floating-point, and list values.
    • Device protocol definitions consistently expose initialized attributes with appropriate defaults.
  • Tests

    • Expanded type and runtime coverage for default values, valid assignments, value validation, and rejection of unsupported undefined values.

Split DeviceAttribute's single generic T into two: V (concrete value
kind, e.g. boolean/string/Int/Float) and T (V | undefined, tracks
presence). fromString()/isValidValue() now return/narrow the concrete
V instead of the abstract T, which TypeScript can prove sound without
"as T" assertions.
Removes the "as T" assertions (and their eslint-disable comments) from
BoolDeviceAttribute, StrDeviceAttribute, IntDeviceAttribute,
FloatDeviceAttribute and IntRangeDeviceAttribute. IntRangeDeviceAttribute
now also uses Int.from() in fromString(), fixing a separate bug where a
plain unbranded number was laundered into the branded Int type.
ListDeviceAttribute's two "as IKey" assertions remain, since its value
kind is chosen by the caller per instance rather than fixed per
subclass - documented as expected in the issue.
Closes#107
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 54314410-b7a3-4b24-aeb3-ecdec9c6a21a

📥 Commits

Reviewing files that changed from the base of the PR and between 9c28faa and 673d2dc.

📒 Files selected for processing (7)
  • src/device/attribute/floatDeviceAttribute.ts
  • src/device/attribute/intDeviceAttribute.ts
  • src/device/attribute/intRangeDeviceAttribute.ts
  • src/device/attribute/numberDeviceAttribute.ts
  • tests/unit/device/attribute/floatDeviceAttribute.spec.ts
  • tests/unit/device/attribute/intDeviceAttribute.spec.ts
  • tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts
💤 Files with no reviewable changes (1)
  • src/device/attribute/numberDeviceAttribute.ts

📝 Walkthrough

Walkthrough

The attribute hierarchy now separates value types from initialization state. Initialized attributes expose defined values through protocol APIs. Parsing and validation methods return concrete value types. Protocol type tests and unit fixtures now use initialized attributes and verify runtime guards.

Changes

Attribute State Refactor

Layer / File(s)Summary
Attribute type model
src/device/attribute/*
DeviceAttribute now uses conditional AttributeValue storage and an IsInitialized generic. Concrete attributes use initialized aliases with true and return concrete types from parsing and validation methods.
Protocol type propagation
src/device/protocol/buttplugIo/..., src/device/protocol/estim2b/..., src/device/protocol/virtual/audio/..., src/device/protocol/zc95/...
Protocol attribute maps and factories now use initialized attribute types. ZC95 power-channel narrowing now targets defined Int values.
Type and runtime validation
tests/type/device/..., tests/unit/device/attribute/..., tests/unit/device/protocol/...
Type tests now expect defined results and reject typed undefined values. Unit tests cover numeric validation, initialized fixtures, explicit assignments, and the untyped runtime guard.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels:patch

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the primary change: refactoring DeviceAttribute generics to remove unsafe type assertions.
Linked Issues check✅ PassedThe changes implement issue #107 by separating value kinds from initialization state, removing targeted assertions, fixing Int conversion, and updating consumers and tests.
Out of Scope Changes check✅ PassedThe changes remain within issue #107 scope, including related numeric validation updates, consumer migrations, and focused tests.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/device-attribute-generics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ds V | undefined
Replace the T extends V | undefined split from the previous commit with the
stricter presence-flag design also proposed in issue #107: DeviceAttribute<V,
IsSet> now uses a boolean IsSet parameter, and the storage/getter/setter type
is computed as `IsSet extends true ? V : V | undefined` (AttributeStorage<V,
IsSet>). This closes the residual gap of the simpler split, where nothing
prevented T from degenerating to exactly `undefined`.
Every Initialized*DeviceAttribute alias now means <..., true> instead of
<V> (e.g. InitializedBoolDeviceAttribute = BoolDeviceAttribute<true>), and
DeviceAttribute.hasValue() narrows to `this is { value: V }` instead of
`this is { value: T }`.
Because TypeScript treats two differently-parameterized instantiations of
the same generic class as mutually non-assignable once a conditional type
makes a parameter measured-invariant, several consumer type declarations
that always construct attributes via createInitialized() had to be updated
from the bare (unset-only) attribute type to the Initialized variant to
keep compiling; this also makes their "always has a value" contract
explicit at the type level:
- EStim2bDeviceAttributes (estim2bDevice.ts) - all fields are always
constructed with a value in estim2bDeviceFactory.ts
- ButtplugIoDeviceAttributes (buttplugIoDevice.ts) - all attributes are
always constructed with a value in buttplugIoDeviceFactory.ts
- PiperVirtualDeviceAttributes.queuing, TtsVirtualDeviceAttributes.speaking/
queuing/queueLength - always constructed via createInitialized()
- Zc95DevicePatternAttributes - always constructed via createInitialized()
in getAttributesFromPatternDetails()
Zc95DevicePowerChannelAttributes keeps its bare (possibly-unset) type since
those attributes are genuinely constructed unset and only get a value once
a power status message is received; Zc95Device.allPowerChannelValuesDefined()
now narrows via an intersection type (`Attr & { value: Int }`) rather than
the Initialized alias, since a type predicate's type must stay assignable
to its original parameter type.
Updated tests accordingly:
- estim2bDevice.spec.ts / buttplugIoDevice.spec.ts test attribute factories
now use createInitialized() to match the stricter production types
- buttplugIoDevice.spec.ts's "undefined value" test now goes through the
untyped AnyDevice interface, since the typed setAttribute() signature no
longer accepts undefined for these attributes (the runtime guard in
buttplugIoDevice.ts is still reachable via that untyped boundary, e.g.
automation scripts)
- zc95Device.spec.ts's power channel test helper now constructs unset
attributes and assigns .value afterward, matching production
- tests/type/*.test-d.ts updated to expect the now-narrower (non-undefined)
setAttribute() return/parameter types for initialized attributes

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/device/attribute/intRangeDeviceAttribute.ts`:
- Line 8: Override isValidValue() in IntRangeDeviceAttribute so it returns
Number.isInteger(value), preserving the runtime Int invariant and preventing
fractional values from reaching createPatternMinMaxChange().
In `@src/device/attribute/numberDeviceAttribute.ts`:
- Line 31: Make NumberDeviceAttribute.isValidValue abstract instead of accepting
every JavaScript number, then implement concrete validation in the Int and Float
subclasses: require Number.isInteger() for Int and Number.isFinite() for Float,
while preserving type narrowing. Add runtime coverage through AnyDevice to
verify invalid numeric values cannot be written via setAttribute().
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea0d3b1c-aff2-4456-8cdd-a599d8544f17

📥 Commits

Reviewing files that changed from the base of the PR and between afc381b and 8223a26.

📒 Files selected for processing (20)
  • src/device/attribute/boolDeviceAttribute.ts
  • src/device/attribute/deviceAttribute.ts
  • src/device/attribute/floatDeviceAttribute.ts
  • src/device/attribute/intDeviceAttribute.ts
  • src/device/attribute/intRangeDeviceAttribute.ts
  • src/device/attribute/listDeviceAttribute.ts
  • src/device/attribute/numberDeviceAttribute.ts
  • src/device/attribute/strDeviceAttribute.ts
  • src/device/protocol/buttplugIo/buttplugIoDevice.ts
  • src/device/protocol/estim2b/estim2bDevice.ts
  • src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts
  • src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts
  • src/device/protocol/zc95/zc95Device.ts
  • tests/type/device/buttplugIo.test-d.ts
  • tests/type/device/estim2b.test-d.ts
  • tests/type/device/virtual/virtual.test-d.ts
  • tests/type/device/zc95/zc95.test-d.ts
  • tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
  • tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts
  • tests/unit/device/protocol/zc95/zc95Device.spec.ts

Comment threadsrc/device/attribute/intRangeDeviceAttribute.ts Outdated
Comment threadsrc/device/attribute/numberDeviceAttribute.ts Outdated
…rename IsSet to IsInitialized
AttributeValue was a flat, non-generic type (BaseAttributeValue | undefined)
used only as the loose/untyped value type at the AnyDevice erasure boundary
(device.ts, scriptVmFactory.ts). AttributeStorage<V, IsSet> was the generic
conditional type computing an attribute's concrete storage/getter/setter
type. Since AttributeStorage<V, IsSet> defaults to AllowedAttributeType |
undefined when called with no type arguments, it can serve both roles -
merged them under the AttributeValue name and dropped AttributeStorage and
the now-redundant BaseAttributeValue (renamed to AllowedAttributeType).
Also renamed the IsSet type parameter to IsInitialized throughout, matching
the existing Initialized*DeviceAttribute naming convention.
No behavioral change; bare `AttributeValue` still resolves to
`AllowedAttributeType | undefined`, identical to the previous flat type.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/device/attribute/numberDeviceAttribute.ts (1)

31-33: ⚠️ Potential issue | 🟠 Major

Keep the numeric validation fix open.

At Line [31], typeof value === 'number' does not prove value is V. The guard accepts fractional values, NaN, and Infinity for Int. It accepts NaN and Infinity for Float. IntRangeDeviceAttribute inherits the same guard.

Make NumberDeviceAttribute.isValidValue() abstract. Implement Number.isInteger() in IntDeviceAttribute and IntRangeDeviceAttribute. Implement Number.isFinite() in FloatDeviceAttribute. Add runtime tests for the untyped validation path.

This is the same unresolved finding as the previous review comment.

Proposed fix
- public override isValidValue(value: unknown): value is V {- return typeof value === 'number';- }+ public abstract override isValidValue(value: unknown): value is V;

Add concrete validators to the numeric subclasses:

publicoverrideisValidValue(value: unknown): valueisInt{returntypeofvalue==='number'&&Number.isInteger(value);}
publicoverrideisValidValue(value: unknown): valueisFloat{returntypeofvalue==='number'&&Number.isFinite(value);}
#!/bin/bashset -euo pipefail
rg -n -C 4 \
'class (Int|Float)DeviceAttribute|isValidValue|Number\.isInteger|Number\.isFinite' \
src/device/attribute
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/device/attribute/numberDeviceAttribute.ts` around lines 31 - 33, Make
NumberDeviceAttribute.isValidValue abstract so numeric subclasses must define
type-appropriate validation. Implement integer-and-number checks with
Number.isInteger in IntDeviceAttribute and IntRangeDeviceAttribute, and
finite-number checks with Number.isFinite in FloatDeviceAttribute. Add runtime
tests covering the untyped validation path, including fractional values, NaN,
and Infinity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@src/device/attribute/numberDeviceAttribute.ts`:
- Around line 31-33: Make NumberDeviceAttribute.isValidValue abstract so numeric
subclasses must define type-appropriate validation. Implement integer-and-number
checks with Number.isInteger in IntDeviceAttribute and IntRangeDeviceAttribute,
and finite-number checks with Number.isFinite in FloatDeviceAttribute. Add
runtime tests covering the untyped validation path, including fractional values,
NaN, and Infinity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0261ca7e-0cad-41ee-9b94-481d7b602702

📥 Commits

Reviewing files that changed from the base of the PR and between 8223a26 and 9c28faa.

📒 Files selected for processing (8)
  • src/device/attribute/boolDeviceAttribute.ts
  • src/device/attribute/deviceAttribute.ts
  • src/device/attribute/floatDeviceAttribute.ts
  • src/device/attribute/intDeviceAttribute.ts
  • src/device/attribute/intRangeDeviceAttribute.ts
  • src/device/attribute/listDeviceAttribute.ts
  • src/device/attribute/numberDeviceAttribute.ts
  • src/device/attribute/strDeviceAttribute.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/device/attribute/intRangeDeviceAttribute.ts
  • src/device/attribute/strDeviceAttribute.ts
  • src/device/attribute/boolDeviceAttribute.ts
  • src/device/attribute/intDeviceAttribute.ts
  • src/device/attribute/listDeviceAttribute.ts
  • src/device/attribute/floatDeviceAttribute.ts

…Value
NumberDeviceAttribute.isValidValue() only checked typeof value === 'number',
so a fractional value passed isValidValue() for Int attributes, and NaN/
Infinity passed for both Int and Float attributes - despite the isValidValue
predicate now claiming `value is V` (Int/Float) after the presence-flag
refactor. These invalid values can reach hardware protocol commands (e.g.
Zc95's createPatternMinMaxChange, EStim2b's power/pulse commands, buttplug.io's
scalar writes) via untyped callers of setAttribute() - the HTTP PATCH
endpoint and automation scripts.
Removed the shared loose check from NumberDeviceAttribute (now abstract
again, inherited from DeviceAttribute) and added concrete overrides:
- IntDeviceAttribute / IntRangeDeviceAttribute: Number.isInteger(value)
- FloatDeviceAttribute: Number.isFinite(value) (matches Float.from()'s own
NaN/Infinity rejection)
Added unit tests for isValidValue on all three classes covering integers,
fractional numbers, NaN, Infinity, and non-number values.
Addresses CodeRabbit review comments on PR #110.
@heavyrubberslave

Copy link
Copy Markdown
MemberAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor DeviceAttribute<T> generics to eliminate "as T" assertions in fromString/related methods

1 participant

@heavyrubberslave