Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
henribru opened this issue Mar 24, 2021 · 15 comments
Open

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

henribru opened this issue Mar 24, 2021 · 15 comments
Labels
🛑 on hold This PR can't be merged until other work is finished scope: types

Comments

@henribru
Copy link

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

@edison1105
Copy link
Member

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>

@HcySunYang
Copy link
Member

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

@LinusBorg
Copy link
Member

duplicate of #1135

@henribru
Copy link
Author

henribru commented Mar 25, 2021

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.

@LinusBorg LinusBorg reopened this Mar 25, 2021
@posva
Copy link
Member

posva commented Mar 26, 2021

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

@henribru
Copy link
Author

henribru commented Apr 2, 2021

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.

@ercmage
Copy link

ercmage commented May 10, 2021

@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
Copy link

ercmage commented May 10, 2021

It seems shallowReactive fixes my problem

@yaquawa
Copy link

yaquawa commented May 26, 2021

I have the similar typing issue here with the setter.

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

@jcppman
Copy link

jcppman commented Jul 11, 2021

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,
});

@KaelWD
Copy link
Contributor

KaelWD commented Oct 1, 2021

microsoft/TypeScript#43826

@posva
Copy link
Member

posva commented Oct 25, 2021

For anybody interested in this feature, you should upvote the necessary feature in TypeScript: microsoft/TypeScript#43826

@Pentadome
Copy link
Contributor

Pentadome commented Feb 10, 2023

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.

@pikax
Copy link
Member

pikax commented Oct 20, 2023

It seems the feature to fix this is being scoped out

microsoft/TypeScript#56158

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

@pikax pikax added 🛑 on hold This PR can't be merged until other work is finished and removed ✨ feature request New feature or request labels Oct 20, 2023
@vincerubinetti
Copy link

vincerubinetti commented Mar 6, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🛑 on hold This PR can't be merged until other work is finished scope: types
Projects
None yet
Development

No branches or pull requests