Skip to content

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

Description

@heavyrubberslave

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 Tres 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 TInt.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

  1. npm run typecheck — full project
  2. 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)
  3. npm test — full suite, particularly tests/unit/device/attribute/*.spec.ts and protocol tests exercising IntRangeDeviceAttribute/ListDeviceAttribute

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions