core icon indicating copy to clipboard operation
core copied to clipboard

Types don't allow refs to be assigned to reactive object properties

Open henribru opened this issue 4 years ago β€’ 15 comments
trafficstars

Version

3.0.7

Reproduction link

https://codesandbox.io/s/zealous-ptolemy-oierp?file=/src/index.ts

Steps to reproduce

  1. Create a reactive object, e.g. const foo = reactive({bar: 3)}
  2. Assign a ref to one of its properties, e.g. foo.bar = ref(5)

What is expected?

Typescript should be fine with assigning a ref to a reactive object. It works at runtime and is even shown in the documentation: https://v3.vuejs.org/guide/reactivity-fundamentals.html#access-in-reactive-objects (the minimal reproduction I've linked is literally just that example)

What is actually happening?

Typescript complains that Ref isn't compatible with number

henribru avatar Mar 24 '21 20:03 henribru

this is intened

https://github.com/vuejs/vue-next/blob/master/packages/reactivity/src/reactive.ts#L73-L84

// only unwrap nested ref
type UnwrapNestedRefs<T> = T extends Ref ? T : UnwrapRef<T>

/**
 * Creates a reactive copy of the original object.
 *
 * The reactive conversion is "deep"β€”it affects all nested properties. In the
 * ES2015 Proxy based implementation, the returned proxy is **not** equal to the
 * original object. It is recommended to work exclusively with the reactive
 * proxy and avoid relying on the original object.
 *
 * A reactive object also automatically unwraps refs contained in it, so you
 * don't need to use `.value` when accessing and mutating their value:
 *
 * ```js
 * const count = ref(0)
 * const obj = reactive({
 *   count
 * })
 *
 * obj.count++
 * obj.count // -> 1
 * count.value // -> 1
 * ```
 */
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

edison1105 avatar Mar 25 '21 00:03 edison1105

This has already been discussed, please see this thread, we will track it there.

HcySunYang avatar Mar 25 '21 01:03 HcySunYang

duplicate of #1135

LinusBorg avatar Mar 25 '21 10:03 LinusBorg

I'm a bit confused about why this is a duplicate. That issue seems to be talking about runtime behavior (and is also related to computed, although I guess that doesn't necessarily matter) while I'm talking about types? Like I mentioned what I'm doing works fine at runtime, it's strictly Typescript that forbids me from doing it. I might of course just be misunderstanding the other issue though.

henribru avatar Mar 25 '21 14:03 henribru

Isn't this a limitation of the typing system:

  • When reading from reactive you always get a raw value (no ref wrapper):
    const data = reactive({ a: ref(0) })
    data.a // 0
    
  • When writing/setting, you need to use the same type, otherwise you would have to add the necessary checks when reading too
    // for this to work
    data.a = ref(2)
    // you need to do this
    unref(data.a) // number
    // because
    data.a // Ref<number> | number
    
    which is really inconvenient

So unless you have a proposal to be able to have the read type different from the set type, I don't think this can be changed:

Screenshot 2021-03-26 at 11 54 28

posva avatar Mar 26 '21 10:03 posva

Good point, I wasn't aware of this limitation. However, it looks Typescript 4.3 will allow different types for setters and getters: https://devblogs.microsoft.com/typescript/announcing-typescript-4-3-beta/

I'm guessing it might be a good while before 4.3+ is adopted well enough to use its features in Vue's types though? If you prefer I'm fine with the issue being closed for now.

henribru avatar Apr 02 '21 07:04 henribru

@LinusBorg Can vue3 make auto unwrapping ref is optional via params in the future?

It's a source of confusion, personally i prefer no auto unwrap even it's ugly (have to write .value everytime), at least it's clear its a ref, and i don't have to fight/double think when assign/use it (lowering dev experience).

example of the problem:

interface Person {
  name: string
  drinkPower: Ref<number>
}

interface State {
  searchText: string
  person: Person | null
}

const state = reactive<State>({
  searchText: '',
  person: null,
})

const cindy: Person = {
  name: 'Cindy',
  drinkPower: ref(10),
}

// Typescript complaint it's different structure.
state.person = cindy

// Have to wrap inside dummy ref to make it work...
state.person = ref(cindy).value

ercmage avatar May 10 '21 10:05 ercmage

It seems shallowReactive fixes my problem

ercmage avatar May 10 '21 10:05 ercmage

I have the similar typing issue here with the setter.

https://codesandbox.io/s/stupefied-wave-ti0bb?file=/src/index.ts

yaquawa avatar May 26 '21 09:05 yaquawa

having the opposite (but technically the same) issue here.

There's no way to make Typescript happy currently except using the ugly @ts-ignore.

My Environment:

Attempt 1:

interface TodoItem {
  title: string;
  completed: boolean;
}
interface TodoList {
 todos: TodoItem[],
};

const todos: Ref<TodoItem> = [];
const state = reactive({
  todos,
});

// TS will complaint about this
state.todos.push({
  title: 'Item',
  completed: false,
});

Attempt 2:

interface TodoItem {
  title: string;
  completed: boolean;
}
interface TodoList {
 todos: TodoItem[],
};


const todos: Ref<TodoItem> = [];

// TS will complaint about this
const state: TodoList = reactive({
  todos,
});

state.todos.push({
  title: 'Item',
  completed: false,
});

Attempt 3:

interface TodoItem {
  title: string;
  completed: boolean;
}
interface TodoList {
 todos: TodoItem[],
};

const todos: Ref<TodoItem> = [];

// TS will complaint about this
const state: UnwrapNestedRefs<TodoList> = reactive({
  todos: [],
});

state.todos.push({
  title: 'Item',
  completed: false,
});

jcppman avatar Jul 11 '21 09:07 jcppman

https://github.com/microsoft/TypeScript/issues/43826

KaelWD avatar Oct 01 '21 14:10 KaelWD

For anybody interested in this feature, you should upvote the necessary feature in TypeScript: https://github.com/microsoft/TypeScript/issues/43826

posva avatar Oct 25 '21 14:10 posva

import type { UnwrapRef } from "vue";
/**
 * This function simply returns the value typed as `T` instead of `Ref<T>` so it can be assigned to a reactive object's property of type `T`.
 * In other words, the function does nothing.
 * You can assign a Ref value to a reactive object and it will be automatically unwrapped.
 * @example Without `asUnreffed`
 * ```
 * const x = reactive({someProperty: 3});
 * const y = ref(2);
 * x.someProperty = y; // This is fine, but sadly typescript does not understand this. "Can not assign Ref<number> to number".
 *                     // The getter is properly typed, this property should always return number.
 *                     // But the setter should also be able to handle Ref<number>.
 *                     // The setter and getter can not be typed differently in Typescript as of now.
 * y.value = 5;
 * console.log(x.someProperty) // expected: 5.
 * ```
 * @example With `asUnreffed`
 * ```
 * const x = reactive({someProperty: 3});
 * const y = ref(2);
 * x.someProperty = asUnreffed(y); // We lie to typescript that asUnreffed returns number, but in actuality it just returns the argument as is (Ref<number>)
 * y.value = 5;
 * console.log(x.someProperty) // expected: 5.
 * ```
 * @see {@link https://vuejs.org/api/reactivity-core.html#reactive} to learn about the Ref unwrapping a Reactive object does.
 * @see {@link https://github.com/vuejs/core/issues/3478} and {@link https://github.com/microsoft/TypeScript/issues/43826} for the github issues about this problem.
 * @param value The value to return.
 * @returns Unchanged `value`, but typed as `UnwrapRef<T>`.
 */
export const asUnreffed = <T>(value: T): UnwrapRef<T> => value as UnwrapRef<T>;

For now, I created this helper function to get around this problem. Works well but it does add a call to a useless function unfortunately.

Pentadome avatar Feb 10 '23 12:02 Pentadome

It seems the feature to fix this is being scoped out

https://github.com/microsoft/TypeScript/issues/56158

Once they allow it, we can support it in Vue.

pikax avatar Oct 20 '23 14:10 pikax

~I'm running into what I think is the limitation being discussed here.~

Ugh of course I figure this out right after I post it. I'll leave it here because it might help someone stumbling on this issue.

Original post

I've tried various things like unref, toRefs, reactive, shallowReactive, UnwrapRef, etc., but I can't make this both functionally work and type-wise work. I can either make it work, or make TypeScript think it works, not both.

If someone could at least tell me whether what I have is solvable with the above, or if I will need to have a @ts-ignore somewhere until TS adds the mentioned functionality. Even better, if someone could point me in the right direction of the best way to solve this... I rarely use things like reactive and toRefs... I tend to just keep it simple and just use ref, and it usually works.

<template>
  <template v-for="(options, key) in dropdowns" :key="key">
    <!-- ts errors on model value and update -->
    <!-- ts thinks selected[key] is Ref<string>, but it's string -->
    <AppSelect
      v-if="selected[key]"
      :options="options"
      :model-value="selected[key]"
      @update:model-value="(value) => (selected[key] = value)"
    />
  </template>

  {{ selected }}
</template>

<script setup lang="ts">
import { onMounted, type Ref, ref, watch } from "vue";
import AppSelect from "./AppSelect.vue";

// full set of dropdowns and options
const dropdowns = ref<Record<string, string[]>>({});

onMounted(async () => {
  // load from some api
  await new Promise((resolve) => setTimeout(resolve, 1000));
  dropdowns.value = {
    Animals: ["Cat", "Dog", "Elephant"],
    Shapes: ["Triangle", "Square", "Circle"],
    Colors: ["Red", "Green", "Blue"],
  };
});

// currently selected values for each dropdown
const selected = ref<Record<string, Ref<string>>>({});

// when available dropdowns change
watch(
  dropdowns,
  () => {
    // select first option by default
    for (const [key, value] of Object.entries(dropdowns.value))
      selected.value[key] = ref(value[0]!);
    // this needs to be a ref because in reality it's a composable that 2-way-syncs with url param

    // more stuff, like removing dropdowns
  },
  { deep: true }
);

// when selected values change
watch(
  selected,
  () => {
    // call another api based on selected value

    console.log(selected.value["Animals"]); // ts thinks this is Ref<string>, but it really prints "Cat"
    console.log(selected.value["Animals"].value); // prints undefined
  },
  { deep: true }
);
</script>

I tried to create a CodeSandbox for this but couldn't get the type errors to show, so here's a zip of a small reproducible repo. Run yarn install && yarn dev.

vue-ts-nested-ref.zip

Was able to fix my particular issue without any ts-ignores by changing the top level const selected = ref into a shallowRef, then update selected[key]s to selected[key].values as appropriate. And because it's now shallow, my "when selected changes" watch with "deep" wouldn't work, and I instead had to dynamically create watchers of each ref within the Object.entries(dropdowns.value) for loop.

Thought I had tried shallowRef as well as shallowReactive, but I guess not.

vincerubinetti avatar Mar 06 '24 03:03 vincerubinetti