Home > Software design >  TypeScript union type on class property does not work as expected
TypeScript union type on class property does not work as expected

Time:12-23

I'm trying to correctly type a property wrapper for our model classes, the property then gets a proxy setter which is supposted to accept the raw, parsed value and the wrapper instance itself. The code it self already works, it's just the typing that causes issues. The Problem is best explained in code.

Playground Link

property wrapper

abstract class BaseProp {}

type RawType = `/${string}/${number}` | null;
type ValType<M> = M | null | undefined;

type ResolvableProxy<M> = NonNullable<Resolvable<M> | RawType | ValType<M>>
class Resolvable<M> extends BaseProp {
    raw!: RawType;
    val!: ValType<M>;

    // has internal logic to resolve how to assign which is called via a proxy
    constructor (model: M) { super(); }

    resolve () {  return this; }
}

model definition

class ModelA {
    name!: string;
}
class ModelB {
    a: ResolvableProxy<ModelA> = new Resolvable(ModelA);

    // the proxy handles properties that are extended from BaseProp
    // constructor () { return new Proxy(); }
}

The models get wrapped into a proxy which has special handling for everything that extends BaseProp, I didn't include that part of the code because I don't think it's relevant here. The proxy works as expected, only the typeing causes issues.

test usage

let test1: ResolvableProxy<ModelA> = new Resolvable(ModelA);
test1.resolve(); // accessable
const test1Name = test1.val!.name; // accessable
test1.raw = '/path/1'; // accessable
test1.val = new ModelA(); // accessable
test1 = '/path/1';
test1 = new ModelA();
test1 = new Resolvable(ModelA);

intended usage
When used on a class property the union type acts differently, and it doesn't make sense to me.

let test2 = new ModelB();
test2.a.resolve(); // not accessable, why?
test2.a.raw = '/path/1'; // not accessable, why?
test2.a.val = new ModelA(); // not accessable, why?
test2.a = '/path/1';
test2.a = new ModelA();
test2.a = new Resolvable(ModelA);

Using "as" allow access to Resolvable members, but that would require rewriting a lot of code which should not be necessary since the type itself should be correct IMO. It also gets overly complicated when looping array-like structures.

(test2.a as Resolvable<ModelA>).raw = '/path/1';
const test3 = (test2.a as Resolvable<ModelA>).val;
(test2.a as Resolvable<ModelA>).resolve(); 

I thought of using explicit getters/setters for each property, but that comes with other issues like "a" not being in the json stringified format at least not without additional handling.

export class ModelC  {
    // @ts-ignore
    #a: Resolvable<ModelA> = new Resolvable(ModelA);
    get a(): Resolvable<ModelA> { return this.#a; }

    set a(v: ResolvableProxy<ModelA>) { this.#a = v as any; }
}

const test4 = new ModelC();
test4.a.resolve();
test4.a.raw = '/path/1';
test4.a.val = new ModelA();
test4.a = '/path/1';
test4.a = new ModelA();
test4.a = new Resolvable(ModelA);

CodePudding user response:

It doesn't error in the first test because it's type is reduced by typechecker

If you break the code block it will error

function testUsage() {
    let test1: ResolvableProxy<ModelA> = new Resolvable(ModelA);
    function f() {
        test1.resolve(); // error
        const test1Name = test1.val!.name; // error
        test1.raw = '/path/1'; // error
        test1.val = new ModelA(); // error
        test1 = '/path/1';
        test1 = new ModelA();
        test1 = new Resolvable(ModelA);
    }
}
  • Related