[@vue/reactivity][typing] Unwrapping breaks classes with private fields.
Version
3.0.5
Reproduction link
https://codesandbox.io/s/refvalue-strips-off-all-private-fields-2ws44?file=/src/index.ts
Steps to reproduce
import { ref, Ref } from "@vue/reactivity";
class Foo {
public bar = 1;
private baz = "a";
}
// Case 1:
interface FooSerivce {
readonly foo: Ref<Foo>;
}
class FooServiceImpl implements FooSerivce {
// error: Type 'Ref<{ bar: number; }>' is not assignable to type 'Ref<Foo>'
foo = ref(new Foo());
}
// IRRELEVANT: consuming the class to get rid of no-unused-vars warning.
new FooServiceImpl();
// Case 2:
class FooService2 {
foo = ref(new Foo());
}
const fooService = new FooService2();
function fooUi(props: { foo: Foo }) {
// mimicing a render mechanism here,
// it's not relevant to the issue.
return `<h1>${props.foo.bar}</h1>`;
}
// Type '{ bar: number; }' is not assignable to type 'Foo'
fooUi({ foo: fooService.foo.value });
/**
* It's true that we won't need those private fields
* when consuming the returned ref. But the real case of
* the `Foo` class in this demo is often some class provided
* by a 3rd party library and we cannot modify it.
* Further, the private field isn't necessarily direct member of
* the wrapped variable type, it could be deep.
* See realistic-example.ts for more realistic example.
*/
What is expected?
Type system is happy with either of the cases.
What is actually happening?
Type system is complaining
A naive suggestion is to change the definition of Ref
generic type to truly reflect the return type of ref
function, as the problem is caused by the mis-alignment between the actual return type of ref
function and the Ref
type definition. ref
function's return type involves Mapped types while Ref<T>
simply has a member value
of type T
.
This is a pretty bad caveat stemming from a combination of things:
This is a limitation in Typescript, when using private properties in classes the Unwrapping that reactivity is doing:
- For TS, the private
baz
property is part of theFoo
type,even though it's private. - When
Foo
is being unwrapped, the resulting interface does not include the private property because TS does not include private properties when reflectingFoo
's properties, and hence, the compiler complains that the types are incompatible.
The code does work at runtime though, as the private field is technically still "there", but for TS to be happy, you need to help TS out:
A. Unwrap it:
interface FooService {
readonly foo: Ref<UnwrapRef<Foo>>;
}
B. Typecast it:
class FooServiceImpl implements FooService {
foo = ref(new Foo()) as Ref<Foo>;
}
In both instances though, you would need to cast it back to its original value when passing it to something that expects an instance of Foo
:
fooUi({ foo: fooService.foo.value as Foo });
None of this is ideal, but short of dropping the Unwrapping behavior, which would be a gigantic breaking change, there's little we can do.
A general recommendation would be to:
- use plain objects for reactivity and keep classes as nonreactive objects where possible - they manages their own state and side-effects and should not be intertwined with reactivity.
- When classes with private fields are used as a property values in a
ref
orreactive
, typecast them when passing them to code expecting an instance of that class as the Unwrapped version is not compatible
Sidenote: Native private fields
the situation is even worse when using native private fields, which will be coming to JS:
class Foo {
public bar = 1;
#baz = "a";
}
Those are completely incompatible with Proxies at runtime as well. So instances of such classes need to be marked as raw so that Vue never replaces them with a reactive proxy:
const fooRef = ref(marRaw(new Foo()))
All of this definitely needs propery documentation in vuejs/docs-next.
Just want to note that an issue from the ethers.js repository is related to this / depends on this issue: https://github.com/ethers-io/ethers.js/issues/4131
I'm inclined to close this issue as there's nothing to fix for us. TS behaves the way it does and we can't work around that. Javascript private fields also seem to be designed not to work with proxies, so there's nothing we can do, really.
I'll close this issue because when you assign a value to a Ref
it will deep unwrap all the refs, this included in class
, meaning the type will be changed!
As a workaround you can define it as a Bail type, that will prevent from specific classes from unwrapping the type, bear in mind the runtime behaviour will be kept the same, use this carefully.
declare module '@vue/reactivity' {
export interface RefUnwrapBailTypes {
classes: Foo
}
}
Other way to solve this issue is marking foo
as raw
that will also prevent the type from being unwrapped, which at runtime should behave correctly.
let foo: Foo = ref(markRaw(new Foo())).value
// @ts-expect-error .value is not Foo
let fooError: Foo = ref(new Foo()).value