Skip to content

Instantly share code, notes, and snippets.

@wycats
Last active April 13, 2024 17:25
Show Gist options
  • Save wycats/25a690694bc7aea769e418f2f6eedf78 to your computer and use it in GitHub Desktop.
Save wycats/25a690694bc7aea769e418f2f6eedf78 to your computer and use it in GitHub Desktop.
Thoughts on readonly accessor decorators in TypeScript

Getter-Only Auto-Accessor Decorators

Broadly speaking, the idea is that you should be able to create an accessor decorator that doesn't expose a setter.

I think the most natural way to express this is for the set function returned by an accessor decorator to have a never return type. The idea is that such a decorator would result in the type system treating the resulting field as if it was a manually written getter without a setter.

Here's an example scenario:

import { Friend } from "hypothetical-lib";

const name: Friend<string> = new Friend();

class Person {
  @name.readonly accessor person: string;

  replace(other: string) {
    name.set(this, other);
  }
}

This mostly feels esoteric because you need to come up with patterns for internally accessing the private state.

The ideal syntax would probably be something like this:

class Person {
  @readonly accessor #person: string;
    
  replace(other: string) {
    this.#person = other;
  }
}

const person = new Person("Daniel");
person.name; // "Daniel"

It's pretty easy to understand why you'd want this, but the only way to accomplish this particular syntax using the current decorators would require manual mutation of the target. This would of course be opaque to TypeScript, so you'd probably end up with something like the above Friend design, or perhaps something like:

import { Fields } from "hypothetical-lib";

const fields: Fields<{ name: string }> = Fields();

class Person {
  @readonly(fields) accessor name;
  @internal(fields) accessor #name;
}

In this case, you'd explicitly declare both #name and name, and use an externally declared object to coordinate between the two decorated properties.

You can implement something like this today in TypeScript, and it works quite well. Unfortunately, there's no way to communicate to TypeScript that the set returned by the @readonly decorator doesn't really exist.

function readonly<Class, T, K extends keyof T>(fields: Fields<T>):
  // target and context have simplified types to avoid complicating the example
  (target: Target<Class>, context: Context<Class> & { name: K }) =>
    { get: (this: Class) => value: T[K]; set: () => never };
///                                                 ~~~~~

I tried to put some flesh on the motivation here, but it's really just about allowing accessor decorators to explicitly create an accessor without a setter. This is a pretty general goal with a lot of possible applications, and, afaict, it's not a terribly big delta from the existing design:

  • TypeScript already knows how to treat a getter but no setter as a readonly property
  • this design uses => never in the return signature of the decorator to communicate the fact that there's no valid setter. This (a) doesn't change the JavaScript API, (b) correctly types the way you would have to implement this behavior in JavaScript, (c) intuitively matches other ways in which TypeScript uses never to drive control-flow-based inference.

On Disallowing readonly on Decorated Auto-Accessors

When I was exploring possible designs, I momentarily thought that I could write:

const person = Fields<{ name: string }>();

class Person {
  readonly @person accessor name: string;
  // or
  @person readonly accessor name: string;  
}

While I'd prefer to be able to abstract the readonly-ness in the decorator type, I was thinking that this would be an acceptable stopgap measure.

Since normal readonly doesn't actually modify the JS, but just tells the type system to disallow writes, I reasoned that I should be able to use the readonly modifier together with a decorated accessor field to communicate the same thing. Unfortunately TypeScript disallows the use of the readonly modifier with accessor fields.

I think this is a mistake, even if it's possible for a decorator to directly communicate its readonly-ness. Today's readonly is purely a way to communicate to TypeScript that a mutable field should not be mutated, and it makes perfect sense with an accessor field:

import { fromStore } from "hypothetical-data-library";

class Person {
  // this creates a getter that gets `name` from the
  // app's store and a setter that updates the store.
  @fromStore('people') accessor name: string;
}

In this kind of situation, it makes perfect sense to want to say that you don't want to allow mutations to name even though the underlying decorator supports it.

I don't see the problem with the combination of readonly and an accessor decorator. Is there something I'm missing?

The Position of TS Keywords

Relatedly, my preference is for the syntax:

class Person {
  readonly @fromStore('people') accessor name: string;
}

The reason I prefer readonly as a prefix is to distinguish it from JavaScript keywords, and to make it easier to see the TS annotations on decorated fields.

But I think that ship has (almost entirely) already sailed, because other keywords are already interleaved.

In other words, I'd prefer:

class Person {
  protected @fromStore('people') accessor name: string;
}

But TS 5 landed on:

class Person {
  @fromStore('people') protected accessor name: string;
}

I assume there's a good reason for this, and my opinion about the position of the readonly keyword is orthogonal to the rest of my argument.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment