vobyjs / oby

A rich Observable/Signal implementation, the brilliant primitive you need to build a powerful reactive system.
MIT License
232 stars 8 forks source link

Observable API #2

Closed mindplay-dk closed 1 year ago

mindplay-dk commented 1 year ago

Hi Fabio,

While voby is still 0.x, I'd like to suggest a change to the Observable API.

In a nutshell, I believe overloading is more or less "bad for everything".

The current API is triple overloaded:

type Observable<T> = {
  (): T,
  ( value: T ): T,
  ( fn: ( value: T ) => T ): T
};

It's misleading. An observable is not a function - it's actually an object with 3 methods.

Modeling it as an overloaded function favors writing code fast and sacrifices readability - producing code where every method call is ambiguous, with $value(...) essentially saying "do something with ...", requiring (A) the programmer to parse to ... expression and figure out what it does, and (B) the run-time to dynamically type-check values.

What I would propose instead:

type Observable<T> = {
  (): T,
  set( value: T ): T,
  update( fn: ( value: T ) => T ): T
};

Words provide semantics, making for more readable code:

const $count = $(0);

const increment = () => $count.update(prev => prev + 1);
const decrement = () => $count.update(prev => prev - 1);
const reset = () => $count.set(0);

I know this is somewhat opinionated, perhaps bordering on bikeshedding territory, and it's a big breaking change. But it might eliminate some (however slight) run-time/complexity overhead, and avoids beginner questions/mistakes such as "how to put a function in an Observable?".

Admittedly, this suggestion is mostly opinionated. It probably comes mostly down to personal preference. Although I think, objectively, seeing the words update or set where those operations take place seems pretty reasonable - overloading does not help readability.

Whether you think writing or reading is more important of course does come down to opinion, but in my experience, code is read more times (by more persons) than written.

Just something to think about and consider. 😇

fabiospampinato commented 1 year ago

Thanks for the suggestion, but I like the current interface better.

Interestingly in the past it basically worked like you are suggesting, then I switched to the current interface and I'm not really regretting the change. Having explicit methods is more obvious if you are unfamiliar with the library, but at the end of the day I don't feel the need for them anymore, after a few days it becomes natural.

Setting a function with the current interface I thought was potentially a bit of a pain in the ass, and it kinda is and it makes the API somewhat uglier, but in practice in the code I've written so far I need to do that fairly rarely, so I think the benefits outweigh the cons in the end.

Also if there's an explicit set method now you can't write value={obs} onChange={obs} anymore, and you can't write onChange={obs.set} either unless that's a bound function, and if that's a bound function that means observables will consume more memory and take slightly longer to instantiate.

Basically while I see some merits in this proposal, and while at the end of the day it largely boils down to a matter of preference, I prefer the current interface.

mindplay-dk commented 1 year ago

This isn't really about familiarity, it's about the amount of parsing you have to do to read the code.

When I see $value(something) in a codebase, I might not even be able to see what something is - I might have to skim back through the codebase to discover which method is actually being called.

Same for method calls, e.g. $value(something()) but you might have to read even more code.

Even if something is a literal expression of some sort, I have to parse that expression to figure out what sort of value it produces and which action is really being performed.

There's even a chance of unintentionally calling the wrong method, so it impacts writing (in the small) as well.

I'm not religiously against overloading, but in my experience, overloading achieves one of two things, either:

  1. Improves clarity and readability: by abstracting from unnecessary details - for example, addFloats and addInts can be reduced to add, removing unhelpful redundant details. In this example, the function performs one operation with different details.

  2. Obfuscates meaning: reduces clarity by adding ambiguity between unrelated functions.

If your function conditionally performs unrelated operations, that's an example of the latter. And I don't mean unrelated in the sense that OOP classes group related functions - I mean unrelated in the sense of one function performing distinct operations, e.g. reads and writes. (Solid even goes as far as to enforce "read/write segregation", the opposite extreme from folding everything into one function. I'm honestly not in that camp either.)

I don't know, you might be the type of developer who can memorize an entire codebase? Some developers have that capacity, some don't. Personally, I need to make my code explicit and unambiguous, or I'll have trouble reading it already next week. I rely very much on patterns where every local statement/expression makes as much sense as possible without having to recall details about the rest of the codebase. Without having to hover over function calls to see which overload I'm invoking. I really try not to reach for the mouse unless I have to. I'd like my code to be readable without a mouse or IDE, e.g. when viewing a diff, in code reviews, on Github, etc.

If you still disagree... oh well, thanks for hearing me out. 🙃

fabiospampinato commented 1 year ago

I don't really disagree with what you're saying, it makes sense, it's perhaps just a different optimization strategy.

I tried it, and I like the current interface better, I haven't found this ambiguity problem to be an actual important problem in practice for the code I've written so far.

One can always make their own $ function that works differently, I need to pick one way here and naturally I'm just going with the one I like best. Mass adoption isn't really a goal of a the project. If you'd like to give this a try but you don't like the interface of observables you can always make your own $ function, the internal symbols that make observables special are exported too (but undocumented).

mindplay-dk commented 1 year ago

One can always make their own $ function that works differently

Can I though?

Observable internally has basically the interface I'd have liked - but it's not exported, only it's type is.

the internal symbols that make observables special are exported too

Yeah, the writable factory function injects some symbols, presumably for run-time type-checking? So merely calling the constructor probably wouldn't work? At least not without injecting these markers - but those aren't exported either.

fabiospampinato commented 1 year ago

Can I though?

Yes.

Yeah, the writable factory function injects some symbols, presumably for run-time type-checking? So merely calling the constructor probably wouldn't work? At least not without injecting these markers - but those aren't exported either.

They are exported: https://github.com/vobyjs/oby/blob/4785ee774b1c3105eaf5cad22f31af2336cb56b7/src/index.ts#L42

mindplay-dk commented 1 year ago

How would you feel about supporting both the overloaded callable and the class API?

This would be a non-breaking change. The functions would simply take (part of) the internal class-based API. So the overloaded function would be instanceof Observable, and the end user could simply choose between the overloaded function, or explicitly call the methods, whatever they prefer.

I didn't suggest this previously, because I only knew of one (bad) way to do that - so I looked long and hard... There's a whole list of really bad ways (eval, proxies, hacks) to make a class that extends a function, but I finally found this approach, which seems simple and elegant:

https://stackoverflow.com/a/74124360/283851

I believe this should perform well, but I haven't benchmarked it.

I could go deeper, but I figured I'd ask first if you're interested in this at all?

I'm only pushing the issue because this was the one big dislike for me with Sinuous, and I really don't feel like going back to that approach - at the same time, I can see some really interesting things about your library. In particular, the fact that you use the standard React JSX transform.

I'd like to try porting this project, which I already ported to Solid from Preact, to Voby. Having already used Sinuous, I already know I'm not going to find it very readable though - and having used maverick as well, I already know I would prefer the explicit methods, which is what you have internally in Observable already anyway.

If you're open to the idea, I might try to create a PR. You don't have to promise to merge it or anything! But I don't want to attempt it if you're already "hard no" on the idea.

I apologize if you feel like I'm being a pain in the ass for pushing this issue! 😅

Feel free to lock this thread, and I will take that kindly and leave well enough alone. 💗

fabiospampinato commented 1 year ago

How would you feel about supporting both the overloaded callable and the class API?

If you mean at the same time, like from the same object, it actually worked like that in the past, but at the end of the day it was just a signal (👀) that the API wasn't well designed, as if I couldn't make up my mind with which way we should go. One way or another I think it's often better to have one way or doing things.

This should be easily implementable on your side though, you just need to make a custom $ function that calls the original one and that attaches get/set/update methods to it, everything else should basically just work already.

If you instead want to extend the Observable class and not have both APIs available on the same object that's more complicated because it might require some internal changes. I don't really want to go there as I see the Observable class as an internal detail that I want to be able to change at any point without breaking anything.

I'd like to try porting this project, which I already ported to Solid from Preact, to Voby. Having already used Sinuous, I already know I'm not going to find it very readable though

That'd be interesting, honestly I'd be surprised if the resulting code would be less readable than Solid's, there's a lot of often unnecessary ceremony in Solid. If you end up doing this port I'd be interesting in taking a look at it to see where the pain points were for you.


Basically I think you should just experimenting with attaching methods to the function that $ gives you, that should be enough for what you need I think.

mindplay-dk commented 1 year ago

This should be easily implementable on your side though, you just need to make a custom $ function that calls the original one and that attaches get/set/update methods to it, everything else should basically just work already.

Yeah, so basically just attaching the same function to the object and narrowing the types?

import $, { Observable, ObservableOptions } from "oby";

interface ExplicitObservable<T> extends Observable<T> {
  set(value: T): T;
  update(fn: (value: T) => T): T;
}

function observable<T>(value: T, options?: ObservableOptions<T>): ExplicitObservable<T> {
  const $value = $(value);

  return Object.assign(
    $value,
    {
      set: $value,
      update: $value,
    }
  );
}

I did think of that, and of course that'll work.

That's just cheating though 😅 ... the narrower type constraints are just a lie - you've still got the unnecessary run-time type-checking in those methods happening with every call.

For example, calling set with a function still won't work.

Returning a function to update still won't work.

If the kernel of the library didn't have this complexity, it would be very easy to add the overloaded type-checking function over a simple, unopinionated, explicit API - you can easily add this dynamic dispatch over an explicit API, but you can't really undo this complexity by adding more complexity.

fabiospampinato commented 1 year ago

Wait I'm not entirely following what you are saying 🤔

you've still got the unnecessary run-time type-checking in those methods happening with every call.

True, but what's the problem with that? Like there's no significant speed boost to be gained here by writing things differently I think.

For example, calling set with a function still won't work.

Just write a set function that works like you want it to then:

function set ( value: T ): T {
  return $value ( () => value );
}

Returning a function to update still won't work.

I'm not sure what you mean by that 🤔 What code specifically won't work?

If the kernel of the library didn't have this complexity, it would be very easy to add the overloaded type-checking function over a simple, unopinionated, explicit API - you can easily add this dynamic dispatch over an explicit API, but you can't really undo this complexity by adding more complexity.

Your implementation is not the only one possible, this can be written differently, like for example you may like more something like this (untested though):

import {SYMBOL_OBSERVABLE, SYMBOL_OBSERVABLE_READABLE} from 'oby';
import {$} from 'voby';

function observable <T> ( value: T, options?: ObservableOptions<T> ) {

  const $value = $(value, options);

  function read (): T {
    if ( arguments[0] === SYMBOL_OBSERVABLE ) return $value ( SYMBOL_OBSERVABLE ); 
    return $value ();
  }

  read.set = function ( value: T ): T {
    return $value ( () => value );
  };

  read.update = function ( fn: ( value: T ) => T ): T {
    return $value ( fn );
  };

  read[SYMBOL_OBSERVABLE] = true;
  read[SYMBOL_OBSERVABLE_READABLE] = true;

  return read;

}

Potentially the native observable that the $ function gives you could be unwrapped to get the underlying class, the library does that internally in some places as it can save some memory in some cases, but currently its "read" and "write" methods are mangled so it'd be problematic to find what they are called at runtime. Potentially this can be rolled back though as mangling those things makes debugging harder.

mindplay-dk commented 1 year ago

Ah, you can get around type-checking in the set function by always passing a callback, I see.

Well, this is why I don't like overloading - straight forward functions don't need any thinking or explaining or knowledge of implementation details.

I don't understand why you want read/write operations entangled like this. I feel very strongly about not adding complexity in favor of typing fewer characters. As you can probably see from this example, complexity begets more complexity.

Sorry, I know I can't change your mind, but this is so fundamental to me, I'm not really sure I want to move past this. 😅

Your library, your call though.

fabiospampinato commented 1 year ago

I don't understand why you want read/write operations entangled like this. I feel very strongly about not adding complexity in favor of typing fewer characters. As you can probably see from this example, complexity begets more complexity.

I'll write an article on this on dev.to one day. But the gist of it is that the code gets significantly cleaner for me, and it consumes less memory (it's probably a bit faster to create too).

Sorry, I know I can't change your mind, but this is so fundamental to me, I'm not really sure I want to move past this. 😅

Yeah sorry, this is mainly just the library that I want to use really, it's not intended to satisfy other people's needs. I understand that you may not want to go with the custom hacky observable function.

mindplay-dk commented 1 year ago

Well, it's slower and consumes more memory when implemented like this.

When implemented on top of the existing function like we're doing here? Of course.

If implemented without the dynamic dispatch, why would fewer operations and less code be slower or consume more memory? It should be the reverse.

If you used prototypical inheritance (like I suggested here), rather than instantiating closures and attaching them as properties on the function object (low we did above) calls should be faster and use less memory - you would skip the type-checking and extra function calls, invoking the functions directly.

I'm not sure what you're comparing with, but if each call site directly references the function it wants to invoke, rather than going through a "decision" function first, how could that be slower?

(well, your library is among the fastest in the benchmarks anyway, and the difference would likely be barely measurable, since the primary workload is DOM operations and not state management in the first place - so it might not matter...)

fabiospampinato commented 1 year ago

I said faster to create, not in general. Sure you could use the same or even less memory, and the same or even less time for instantiation, by leveraging the prototype, in theory, but I write a lot of code like this:

value={foo} onChange={foo}

With the API that you want you'd be instead tempted to write this:

value={foo} onChange={foo.set}

But that just fails silently, the "set" function isn't bound to anything. You'd have to write like:

value={foo} onChange={x => foo.set ( x )}

So we are back at 1 function per observable, and if you need to use that N times it's N functions that you need to create. Moreover that wrapper arrow function is just a regular arrow function, the library can only treat it as such, while if what you are passing on is an observable in some cases the library can detect it, throw away the wrapper function, and just hold a reference to the underlying Observable class, and if you don't have other references to the wrapper function it can just be garbage collected, potentially keeping 0 functions in memory.

In theory you could make your "set" and "update" functions special too, so that they could be unwrapped, but then you'd be creating 3 functions per observable (get/set/update) so instantiation time will be slower and until those get garbage collected (or if they can't be garbage collected) you'd be consuming more memory.

At the end of the day we are splitting hairs here, using your approach wouldn't make memory usage blow up hugely, but I prefer the resulting code with the current observables, and memory usage and instantiation time is a bit better for the code I write, so I don't see a reason to change it, if you want the less efficient/clean but more explicit API you have the tools to implement it yourself in userland already, if you'd like something like that to ship with the library I don't see that happening.

mindplay-dk commented 1 year ago

This is supposed to work?

<input value={foo} onChange={foo}/>

I just tried that, it didn't update foo - the onChange callback receives an Event, not a value, according to the JSX types?

Wouldn't it be cleaner to have a directive of some sort for that?

e.g. <input $value={foo}> rather than <input value={foo} onChange={foo}> ?

Or just a component - I like e.g. <Input $value={value}/> which would be type-hinted to expect an observable, which means I can't accidentally forget either the value or the input event, the component type guarantees a valid component instance.

(well, in Sinuous I used this pattern... in Solid we have this "read/write segregation", which as mentioned I think is taking separation of concerns too far... one of my favorite Sinuous-isms is being able to pass observables to components via a single prop...)

fabiospampinato commented 1 year ago

This is supposed to work? I just tried that, it didn't update foo - the onChange callback receives an Event, not a value, according to the JSX types?

It is supposed to work like it's working, otherwise how is one going to get the event object?

Or just a component - I like e.g. <Input $value={value}/> which would be type-hinted to expect an observable, which means I can't accidentally forget either the value or the input event, the component type guarantees a valid component instance.

In practice you'd build your own Input component on top of the native input component, that's what I'm doing, but I don't like that value and onChange are merged into a single prop, that seems to leak details that the Input component shouldn't be aware of. Also if I want to only pass a read-only observable for the value in your example how is the Input component supposed to know that that's read-only and that calling it would throw at runtime? Like that just seems the wrong way to write it in many ways.

mindplay-dk commented 1 year ago

It is supposed to work like it's working, otherwise how is one going to get the event object?

That's what I thought. But I don't understand, what was the example you were showing here then?

In practice you'd build your own Input component on top of the native input component, that's what I'm doing, but I don't like that value and onChange are merged into a single prop, that seems to leak details that the Input component shouldn't be aware of.

I've taken us drastically down a sidetrack here 😅

In my opinion, handling input/output behavior in an input component isn't leaking details - it's where I want that responsibility handled, and it's one of the reasons I want a component to begin with. It becomes particularly relevant when building custom controls that don't have any native input elements to begin with, for example an image cropper.

If I'm using a UI library and a component model, I want the component implementation to decide, for example, if it should use onChange or onInput - it's handling those details in a uniform way. For example, I want e.g. a numeric input to handle validation and ensure we only receive numeric values. I do not want the control to manage it's own state, because it's just a control - the state goes in the form or whatever else is using the control.

It's a matter of where you choose to place responsibilities - if I've chosen to have my input component handle validation and guard against invalid input, then the reverse is true: exposing an onChange property would be leaking an implementation detail. The consumer shouldn't know what type of input element I'm using.

Also if I want to only pass a read-only observable for the value in your example how is the Input component supposed to know that that's read-only and that calling it would throw at runtime?

The same way we've always done it with DOM elements?

https://codesandbox.io/s/try-voby-6k3sp9?file=/src/index.tsx

So yes, technically, this control could mutate the state - it just promises not to, and the component handles that as an implementation detail.

Essentially, yeah, in reactive frameworks, I go for two-way binding in my controls. Whether or not you like that is mostly a matter of preference or opinion. I could argue the point either way. I don't think one or the other is objectively better or worse.

fabiospampinato commented 1 year ago

That's what I thought. But I don't understand, what was the example you were showing https://github.com/vobyjs/oby/issues/2#issuecomment-1288121333 then?

<Input value={value} onChange={value} />
<Numbox value={value} onChange={value} />
<Slider value={value} onChange={value} />
<Whatever value={value} onChange={value} />

The same way we've always done it with DOM elements?

That code in wrong, I can't pass my input an ObservableReadonly now, or a () => number which doesn't make sense. And if you change the type you get a type error in that $value call inside onInput, with no way to definitely check for this at runtime, even assuming having this check in userland makes sense.

Like I feel pretty strongly that that code is wrong, but I don't know if I have a watertight argument for it, and ultimately it doesn't really matter to me, like that's not the code I want to write anyway.

mindplay-dk commented 1 year ago

Heh, I thought you meant <input value={value} onChange={value}/> would work - you were only showing the property names in that example, and I assumed you meant native input elements, but you were only suggesting you'd write components following the same naming convention, ha, gotcha. 😄

Well, that makes more sense, I think I see what you're trying to do now - something more like this?

https://codesandbox.io/s/voby-custom-control-q0281x?file=/src/index.tsx

So your value prop will be typed as () => number and onChange as (value: number) => void, so your components aren't directly dependent on Observables? You can pass anything that satisfies those types, and it'll work.

Well, this is essentially read/write segregation, and I can see now why that gives the component consumer more control - for example, the consumer can validate the value that comes from onChange and only allow it into the model if it's acceptable, or maybe apply a transformation first, and so on.

So you've changed my mind, but now I think you've talked me around to the Solid way - enforcing read/write segregation is probably a good thing. It honestly never clicked for me until just now.

But since you seem to prefer read/write segregation in your components, do you have a reason for preferring a state abstraction without any read/write segregation?

Because it does kind of seem like your approach has the same drawback in that regard as what I've been suggesting?

fabiospampinato commented 1 year ago

Yeah exactly. I've touched on this topic a bit here.

But since you seem to prefer read/write segregation in your components, do you have a reason for preferring a state abstraction without any read/write segregation?

Because it does kind of seem like your approach has the same drawback in that regard as what I've been suggesting?

This would require a longer answer, but the gist of it is that from my point of view read/write segregation (or being explicit by writing more code in general) is good if it helps preventing bugs, and it's bad as it goes against writing cleaner code.

Solid in some ways went for cleaner code where using segregation would have helped in discovering bugs (transforming props to getters), and it went with read/write segregation where it doesn't help you discover any bugs but it makes the code uglier (segregated createSignal).

Basically there are maybe 3 different aspects to think about:

  1. The interface of the observable: it's read/write segregated in Solid but it's not read/write segregated in Voby. My argument for this is that there's no reasonable scenario where segregating this helps you discover bugs, so there's no point in writing uglier code if it gives you nothing. And my argument basically boils down to how TS is seen as a language, Ryan doesn't really like TS, TS is kind of an afterthought for him almost, while I'm willing to bend over backwards to satisfy TS, because fundamentally I think TS brings immense value to the table. Now, if we are assuming that not using TS or not writing idiomatic TS is unreasonable, under what scenario would the segregated API help you discover bugs? Say I write <Input value={value} /> and value is an Observable, not an ObservableReadonly, so in theory I'm giving the Input component the ability to write even though I should only give it the ability to read, how could this possibly blow up in my face while writing idiomatic (or really "non absurd") TS code, assuming that prop has type { value: string | (() => string) }? I'd argue there's no scenario where that's the case.

  2. Types of props are somewhat related to this, are you segregating observables from non-observables values or not? In Solid if you say that you accept a number as a prop you are also effectively accepting a signal that points to a number, as props get transformed into getters and you can't tell them apart at the type level. In Voby that doesn't happen, signals and primitives are segregated at the type-system level and you need to be explicit in saying what exactly you support. Now can this help prevent bugs? Absolutely, maybe I have a component that says it accepts a string for a prop, I pass it a signal to a string in Solid, I don't get any errors and the thing just doesn't update itself as I expected it to because this particular component really only supports a string, not a signal to a string. In Voby this silent error is impossible because you'd get an error if the component says it only supports a string but you pass it an observable to a string. So in this case I went for the uglier version, but that helps prevent bugs.

  3. This is also something to be considered at the interface level for components, do we have 2 props, one for reading and one for writing (value and onChange), or do we have a single prop that does both? We've seen already how a single prop is less flexible and kind of leaks some expectations that the component has. Two props are more flexible (I could only provide the "value" prop, I could only provide a read-only "value" prop, I could intercept the read or the write and apply some transformation etc., which I can't really do with a single prop). So in the components I write I want this flexibility and segregation. Solid generally kind of sees things the same way in this regard.

So basically we agree on 1 aspect, and disagree on the other 2, where Voby is stricter with types because that helps prevent bugs, and Voby is looser with the interface of observables, because that doesn't help prevent bugs.

mindplay-dk commented 1 year ago
  1. [...] In Solid if you say that you accept a number as a prop you are also effectively accepting a signal that points to a number, as props get transformed into getters and you can't tell them apart at the type level.

You're saying it will essentially widen your declared types?

Because that would be bad - but I don't see that happening?

function Hello(props: { world: string }) {
  return <h1>Hello {props.world}</h1>;
}

function App() {
  const [getWorld, setWorld] = createSignal('World');

  return (
    <div>
      <Hello world={getWorld} />
    </div>
  );
}

While <Hello world={getWorld} /> executes without problems, it does fail the type-check like I'd expect. (Maybe they improved the type-checking strictness?)

  1. [...] We've seen already how a single prop is less flexible and kind of leaks some expectations that the component has. Two props are more flexible (I could only provide the "value" prop, I could only provide a read-only "value" prop, I could intercept the read or the write and apply some transformation etc., which I can't really do with a single prop)

I'm on board with that now, although this still seems like it can lead to bugs.

So take this:

function DatePicker(props: { getDate(): Date, setDate?(value: Date): void }) {
  return <div>...</div>
}

And note that setDate is optional here.

So if you're building UI controls, and you want them to support optional reactivity, this can lead to bugs. Say, if you have two different forms, (A) one with a bunch of optional data inputs that you don't bind to states (using <form action="..."/> or new FormData(...)) and (B) one with states bound to a reactive model, in the latter case, you could accidentally forget the setter.

I'm not saying I think this is going to happen all the time - but if you're intentionally building your entire library of form controls with optional setters, type-checking won't protect you from accidental omissions like this.

Circling back to (1)

under what scenario would the segregated API help you discover bugs?

It wouldn't in and of itself, but it would more naturally lead you into read/write segregation in your components, I think? "What's good for the goose..."

I think the only thing that would fully protect you from this is some kind of explicit type hierarchy for mutable/immutable values - so a ReadWriteAccessor type being distinct from ReadAccessor maybe? Although I could see this probably leading to other ergonomic problems... 🤔

fabiospampinato commented 1 year ago

You're saying it will essentially widen your declared types?

Kind of, it kinda tricks TS because the code that will be actually executed is not the one that TS thinks will be executed.

Because that would be bad - but I don't see that happening?

You can do this:

<Hello world={getWorld} />

But idiomatic Solid code would be more like the following:

<Hello world={getWorld()} />

Basically you may get in a situation like this:

/* @refresh reload */

import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';

function Hello(props: { world: string }) {
  return <h1>Hello {props.world}</h1>;
}

function App() {
  const [getWorld, setWorld] = createSignal('World');

  setInterval(() => {
    setWorld((world) => (world += '!'));
  }, 500);

  return (
    <div>
      <Hello world={getWorld()} />
    </div>
  );
}

render(App, document.getElementById('root')!);

Where Hello says it only accepts a string, it looks like we are passing it a string, but we are actually passing it a function via a getter, and Hello is now reactive. Like if Hello truly received a string this couldn't possibly work in Solid.

It doesn't seem so bad when things are reactive when you didn't expect them to, but symmetrically you can get in a situation where things are not reactive when you expected them to. Both of them are mistakes in my eyes, the types are just incorrect here.

I'm not saying I think this is going to happen all the time - but if you're intentionally building your entire library of form controls with optional setters, type-checking won't protect you from accidental omissions like this.

Yeah but that's just buggy code, you wanted things to update but you didn't pass a setter, you just wrote buggy code, nothing will protect from buggy logic like that, TS can't possibly know what you intended to do.

It wouldn't in and of itself, but it would more naturally lead you into read/write segregation in your components, I think? "What's good for the goose..."

I doesn't for me 🤷‍♂️

so a ReadWriteAccessor type being distinct from ReadAccessor maybe?

I'm not sure what you mean exactly, but the Observable and ObservableReadonly types seem to encode that distiction.