Problem
Several DeviceAttribute subclasses (BoolDeviceAttribute, StrDeviceAttribute, IntDeviceAttribute, FloatDeviceAttribute, IntRangeDeviceAttribute) have // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + as T in their fromString() implementations, e.g.:
// boolDeviceAttribute.ts
public fromString(value: string): T {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (value === '1') as T;
}
Root cause: DeviceAttribute<T extends AttributeValue> uses a single generic T for both "what kind of value" (boolean) and "is it possibly unset" (| undefined). Since T could theoretically be instantiated narrower than boolean (e.g. a hypothetical BoolDeviceAttribute<true>), TypeScript can't prove a plain boolean return value satisfies the abstract T — this is a well-known TS limitation: generic type parameters are never narrowed by control flow within their own defining scope, no matter how the constraint is written (verified experimentally — several constraint-tightening attempts, including F-bounded/self-referencing constraints, either hit circular-constraint errors or still failed with 'T' could be instantiated with an arbitrary type which could be unrelated to 'boolean').
IntRangeDeviceAttribute.fromString has a related, separate bug: return res as T — res is a plain unbranded number, so this assertion is also currently laundering number into the branded Int type unsafely, independent of the generic issue.
Solution (verified via TypeScript compiler API, in-memory, against strict mode)
Split the value-kind and presence-tracking into two separate generic parameters, and change fromString/isValidValue to return the concrete value kind (V) instead of the abstract storage type (T):
abstract class DeviceAttribute<V extends BaseAttributeValue, T extends V | undefined = V | undefined> {
private _value: T;
get value(): T { ... }
set value(value: T) { ... }
abstract fromString(value: string): V; // concrete, not T
abstract isValidValue(value: unknown): value is V;
}
class BoolDeviceAttribute<T extends boolean | undefined = boolean | undefined> extends DeviceAttribute<boolean, T> {
fromString(value: string): boolean {
return value === '1'; // no assertion, no disable comment
}
isValidValue(value: unknown): value is boolean {
return typeof value === 'boolean';
}
}
This works because fromString no longer claims to know anything about T — it only promises V (always concrete per subclass), which is always true regardless of how narrow T gets instantiated.
Follow-up refinement: presence flag instead of T extends V | undefined
The simple split above still technically permits a degenerate BoolDeviceAttribute<undefined> (nothing prevents T from narrowing to exactly undefined) — harmless in practice (verified: fromString stays sound regardless; only the unrelated .value = ... assignment correctly fails for such an instance) since nothing in the codebase constructs attributes that way, but not airtight. A stricter version replaces T with a boolean presence flag and computes the storage type:
abstract class DeviceAttribute<V extends BaseAttributeValue, IsSet extends boolean = false> {
private _value: IsSet extends true ? V : V | undefined;
get value(): IsSet extends true ? V : V | undefined { ... }
...
}
class BoolDeviceAttribute<IsSet extends boolean = false> extends DeviceAttribute<boolean, IsSet> {
fromString(value: string): boolean { return value === '1'; }
}
Verified (compiler API, resolved types dumped directly, not just "no errors"):
BoolDeviceAttribute<true>.value → boolean
BoolDeviceAttribute.value → boolean | undefined (default IsSet=false)
BoolDeviceAttribute<false>.value → boolean | undefined
BoolDeviceAttribute<undefined> → compile error: "Type 'undefined' does not satisfy the constraint 'boolean'"
This closes the gap for free — undefined (or any non-boolean) is no longer a valid second type argument at all, since the constraint only admits true/false.
Trade-off: bigger migration — every Initialized*DeviceAttribute type alias changes meaning (e.g. InitializedBoolDeviceAttribute = BoolDeviceAttribute<boolean> → BoolDeviceAttribute<true>), and hasValue(): this is { value: T } likely becomes this is { value: V }.
Decision needed: go with the simpler V, T extends V | undefined split (smaller diff, leaves the harmless T=undefined edge case technically possible), or the presence-flag version (fully closes it, bigger migration).
Important caveat: ListDeviceAttribute can't be fully fixed by either approach
ListDeviceAttribute<IKey, IValue, V>'s "value kind" (IKey) is genuinely chosen by the caller per attribute instance (e.g. ListDeviceAttribute<Int, string> in zc95Device.ts:32, ListDeviceAttribute<string, string> elsewhere) — unlike boolean/string/Int/Float which are fixed per subclass. So fromString's two as V assertions in listDeviceAttribute.ts (lines 63, 68) remain necessary either way — same underlying "generic can't be narrowed within its own scope" limitation, just because the value-kind itself is meant to vary, not because of a fixable design flaw.
Files affected (assuming the simpler split; presence-flag version touches a few more)
src/device/attribute/deviceAttribute.ts — base class, AttributeValue/BaseAttributeValue, isValidAttributeValue helper, delete now-unused NotJustUndefined
src/device/attribute/boolDeviceAttribute.ts
src/device/attribute/strDeviceAttribute.ts
src/device/attribute/numberDeviceAttribute.ts (shared abstract base for int/float, stays generic over V)
src/device/attribute/intDeviceAttribute.ts
src/device/attribute/floatDeviceAttribute.ts
src/device/attribute/intRangeDeviceAttribute.ts (also fixes the separate res as T → Int.from(res) unbranded-number bug)
src/device/attribute/listDeviceAttribute.ts — mechanical update to new base signature only; assertions stay (see caveat above)
External consumers (device.ts's AttributeValue, AttributeValueOf, WithUntypedAttributes, AnyDevice; all isValidAttributeValue() call sites in estim2bDevice.ts; ListDeviceAttribute usages in zc95Device.ts and test files) verified unaffected — AttributeValue's public meaning stays identical, and .value's external type contract doesn't change.
Verification plan
npm run typecheck — full project
npm run lint — confirm all no-unsafe-type-assertion/consistent-type-assertions disables gone from bool/str/int/float/intRange; list's two remain (documented, expected)
npm test — full suite, particularly tests/unit/device/attribute/*.spec.ts and protocol tests exercising IntRangeDeviceAttribute/ListDeviceAttribute
Problem
Several
DeviceAttributesubclasses (BoolDeviceAttribute,StrDeviceAttribute,IntDeviceAttribute,FloatDeviceAttribute,IntRangeDeviceAttribute) have// eslint-disable-next-line @typescript-eslint/consistent-type-assertions+as Tin theirfromString()implementations, e.g.:Root cause:
DeviceAttribute<T extends AttributeValue>uses a single genericTfor both "what kind of value" (boolean) and "is it possibly unset" (| undefined). SinceTcould theoretically be instantiated narrower thanboolean(e.g. a hypotheticalBoolDeviceAttribute<true>), TypeScript can't prove a plainbooleanreturn value satisfies the abstractT— this is a well-known TS limitation: generic type parameters are never narrowed by control flow within their own defining scope, no matter how the constraint is written (verified experimentally — several constraint-tightening attempts, including F-bounded/self-referencing constraints, either hit circular-constraint errors or still failed with'T' could be instantiated with an arbitrary type which could be unrelated to 'boolean').IntRangeDeviceAttribute.fromStringhas a related, separate bug:return res as T—resis a plain unbrandednumber, so this assertion is also currently launderingnumberinto the brandedInttype unsafely, independent of the generic issue.Solution (verified via TypeScript compiler API, in-memory, against strict mode)
Split the value-kind and presence-tracking into two separate generic parameters, and change
fromString/isValidValueto return the concrete value kind (V) instead of the abstract storage type (T):This works because
fromStringno longer claims to know anything aboutT— it only promisesV(always concrete per subclass), which is always true regardless of how narrowTgets instantiated.Follow-up refinement: presence flag instead of
T extends V | undefinedThe simple split above still technically permits a degenerate
BoolDeviceAttribute<undefined>(nothing preventsTfrom narrowing to exactlyundefined) — harmless in practice (verified:fromStringstays sound regardless; only the unrelated.value = ...assignment correctly fails for such an instance) since nothing in the codebase constructs attributes that way, but not airtight. A stricter version replacesTwith a boolean presence flag and computes the storage type:Verified (compiler API, resolved types dumped directly, not just "no errors"):
This closes the gap for free —
undefined(or any non-boolean) is no longer a valid second type argument at all, since the constraint only admitstrue/false.Trade-off: bigger migration — every
Initialized*DeviceAttributetype alias changes meaning (e.g.InitializedBoolDeviceAttribute = BoolDeviceAttribute<boolean>→BoolDeviceAttribute<true>), andhasValue(): this is { value: T }likely becomesthis is { value: V }.Decision needed: go with the simpler
V, T extends V | undefinedsplit (smaller diff, leaves the harmlessT=undefinededge case technically possible), or the presence-flag version (fully closes it, bigger migration).Important caveat:
ListDeviceAttributecan't be fully fixed by either approachListDeviceAttribute<IKey, IValue, V>'s "value kind" (IKey) is genuinely chosen by the caller per attribute instance (e.g.ListDeviceAttribute<Int, string>inzc95Device.ts:32,ListDeviceAttribute<string, string>elsewhere) — unlikeboolean/string/Int/Floatwhich are fixed per subclass. SofromString's twoas Vassertions inlistDeviceAttribute.ts(lines 63, 68) remain necessary either way — same underlying "generic can't be narrowed within its own scope" limitation, just because the value-kind itself is meant to vary, not because of a fixable design flaw.Files affected (assuming the simpler split; presence-flag version touches a few more)
src/device/attribute/deviceAttribute.ts— base class,AttributeValue/BaseAttributeValue,isValidAttributeValuehelper, delete now-unusedNotJustUndefinedsrc/device/attribute/boolDeviceAttribute.tssrc/device/attribute/strDeviceAttribute.tssrc/device/attribute/numberDeviceAttribute.ts(shared abstract base for int/float, stays generic overV)src/device/attribute/intDeviceAttribute.tssrc/device/attribute/floatDeviceAttribute.tssrc/device/attribute/intRangeDeviceAttribute.ts(also fixes the separateres as T→Int.from(res)unbranded-number bug)src/device/attribute/listDeviceAttribute.ts— mechanical update to new base signature only; assertions stay (see caveat above)External consumers (
device.ts'sAttributeValue,AttributeValueOf,WithUntypedAttributes,AnyDevice; allisValidAttributeValue()call sites inestim2bDevice.ts;ListDeviceAttributeusages inzc95Device.tsand test files) verified unaffected —AttributeValue's public meaning stays identical, and.value's external type contract doesn't change.Verification plan
npm run typecheck— full projectnpm run lint— confirm allno-unsafe-type-assertion/consistent-type-assertionsdisables gone frombool/str/int/float/intRange;list's two remain (documented, expected)npm test— full suite, particularlytests/unit/device/attribute/*.spec.tsand protocol tests exercisingIntRangeDeviceAttribute/ListDeviceAttribute