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

Variant accessors for index signatures and mapped types #43826

Open
5 tasks done
pikax opened this issue Apr 26, 2021 · 21 comments
Open
5 tasks done

Variant accessors for index signatures and mapped types #43826

pikax opened this issue Apr 26, 2021 · 21 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@pikax
Copy link

pikax commented Apr 26, 2021

Suggestion

πŸ” Search Terms

variant accessors index signatures record

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Support for having Variant Accessors with index signature.

πŸ“ƒ Motivating Example

// Simple  usage

interface NumberMap { 
  get [k: string](): number;
  set [k: string](val: string | number);
}

const a: NumberMap = {} 

a.test = '1';
a.test === 1;


// more complex

// makes number properties of an object to allow set `string`
type MakeStringAsNumber<T extends Record<string, any>> = {
  get [ K in keyof T]: T[K]
  set [ K in keyof T]: T[K] extends number ? T[K] | string : T[K]
}

declare const a: MakeStringAsNumber<{ a: boolean, b: number, c: string }>
a.b = '42'; // I want to have this avalible

a.c = '112'

// @ts-expect-error 'a' is boolean
a.a = '2'

πŸ’» Use Cases

The use case for this feature is vue 3 reactivity.

In Vue 3 there's Ref<T> (simplified as { value: T}) and Reactive<UnwrappedRef<T>> (unwraps {value: T} to T, recursive)

This feature would allow us to generate a type which can accept a Ref<T> | T

Basically:

const r = ref({ a: 1} )

r.value.a = 1

const a = reactive(r)
a.a // 1

On a reactive/ref object the set in typescript must be UnwrappedRef<T> which is not true, because when you assign to a reactive it will unwrap the value:

const r = ref({a:1});
const a = ref({
  r
}) // results in `{ r: { a: 1 } }` 

a.r = r; // typescript error  because `r` is `Ref<T>` instead of `UnwrappedRef<T>`, but it works at runtime.
@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Apr 26, 2021
@rozzzly
Copy link

rozzzly commented May 24, 2021

This would be very useful functionality which I'm saddened to see lacking. Here's my use case: I'm working on entity normalization package use with redux which allows for ORM-like CRUD functionality, relations, custom indexes, etc.

A contrived example would look something like:

// wont ever actually be used, ModelRecord will be a proxy object
class ModelRecord { 
	// required so allow fields to be filtered with conditional types
	// otherwise, `{}` is assignable to ModelRecord 
	protected _isModelRecord: true;
}

interface OwnerRecord extends ModelRecord {
	name: string;
	dogs: DogRecord[];
}

interface DogRecord extends ModelRecord {
	breed: string;
	name: string;
	owner: OwnerRecord;
}
const owner = OwnerModel.create({ name: 'John Smith' });
const dog = DogModel.create({ breed: 'Beagle', name: 'Sparky' });
dog.owner = owner; // set by refence to another record
dog.owner = owner.id; // set by id (which is how the data is actually stored behind the scenes)

I had hoped to be able to create a mapped type that would allow properties assignable to Record (or Record[], etc) be overwritten to allow this assignment by a different type:

export type Normalized<RecordFields extends ModelRecord> = {
	[Field in keyof RecordFields]: RecordFields[Field] extends ModelRecord
		? /** What syntax could work here? **/
		: RecordFields[Field]
};

but there's no syntax to for anything like that. I thought perhaps something like how interfaces and types can have unnamed functions eg:

interface Example {
	(something: number): string;
}

but

{
	get(): OwnerRecord;
	set(value: OwnerRecord | string);
}

would just be interpreted as an object with two properties named get and set that are both functions. Even though I only want to change the "settable" type of a subset of properties, @pikax's suggestion could work for me without too much trouble.

export type Normalized<RecordFields extends ModelRecord> = {
	get [Field in keyof RecordFields](): RecordFields[Field];
    set [Field in keyof RecordFields](value: RecordFields[Field] extends ModelRecord
		? RecordFields[Field] | string
        : RecordFields[Field]
    );
};

but alas, it's not possible right now. This functionality would greatly improve the usefulness of variant accessors. Right now I can manually create the setters/getters like so:

interface DogRecord extends ModelRecord {
	color: string;
	name: string;
	get owner(): OwnerRecord;
	set owner(value: string | OwnerRecord);
}

but that's rather tedious, and not nearly as extensible as a variant accessors for index signature. For example, if I wanted to create an index on OwnerRecord where each key is the name of a dog and each property was a DogRecord this would be trivial:

type RecordIndex<K, T> = {
	get	[I in K](): T
    set	[I in K](value: string | T);
}

interface OwnerRecord {
	name: string;
	dogs: RecordIndex<string, DogRecord>;
}
// the getter works as expected
owner.dogs.Sparky === DogModel.getOneByProperty('name', 'Sparky')
// the setter works as expected
owner.dogs.Hotdawg = DogModel.create({ name: 'Hotdawg', breed: 'Dachshund' })
const princess = DogModel.create({ name: 'Princess', breed: 'Poodle' });
owner.dogs.Princess = princess.id;

but that's just not possible without variant accessors for index signatures. I'm sure there are many other developers who would love this functionality and I hope to see it implemented.

One interesting thing I noticed in @pikax's examples is the lack of () following the property name in the second example:

interface NumberMap { 
  get [k: string](): number;
  set [k: string](val: string | number);
}
type MakeStringAsNumber<T extends Record<string, any>> = {
  get [ K in keyof T]: T[K]
  set [ K in keyof T]: T[K] extends number ? T[K] | string : T[K]
}

not sure if this is unintentional, an alternative syntax, or implies some different behavior..

@pikax
Copy link
Author

pikax commented May 24, 2021

One interesting thing I noticed in @pikax's examples is the lack of () following the property name in the second example:

interface NumberMap { 
  get [k: string](): number;
  set [k: string](val: string | number);
}
type MakeStringAsNumber<T extends Record<string, any>> = {
  get [ K in keyof T]: T[K]
  set [ K in keyof T]: T[K] extends number ? T[K] | string : T[K]
}

not sure if this is unintentional, an alternative syntax, or implies some different behavior..

yes that was not intentional πŸ˜… Tbh I didn't put too much thought on the implementation, I can update the example showing a better API.

Other suggestion would be:

type GetterSetter<TGet, TSet> = /* internal implementation*/;

type MakeStringAsNumber<T extends Record<string, any>> = {
 [ K in keyof T]: T[K] extends number ? GetterSetter<T[K], T[K] | string> : T[K]
}

Having the special type GetterSetter would allow a finer control without a syntax change, because with {get, set} you are required to have the exact same keys on both and it might be a bit more tedious to have manage conditions across a few get/set

@justin-schroeder
Copy link

Would be hugely useful. I actually just opened an SO post today specifically around this issue for pretty much the same reason @pikax needs it in Vue 3 (although sort of inverse). Stack Overflow Here.

@jcalz
Copy link
Contributor

jcalz commented May 28, 2021

Should this issue be renamed to "variant accessors for index signatures and mapped types"?

@rozzzly
Copy link

rozzzly commented May 28, 2021

@pikax

Having the special type GetterSetter would allow a finer control without a syntax change, because with {get, set} you are required to have the exact same keys on both and it might be a bit more tedious to have manage conditions across a few get/set

I had the same thought about some magic built-in type such as GetterSetter. This would certainly allow for much more precise control, but also would allow for something like

type FooBar = GetterSetter<'Foo', 'Foo' | 'Bar'>;
let foo: FooBar;
foo = 'Foo'; // βœ… typeof foo === 'Foo' 
foo = 'Bar'; // ❓typeof foo === 'Foo'

Assuming an unrestricted GetterSetter, that second line would also be 'Foo' andβ€”to me at leastβ€”that doesn't seem "right". While I would love the freedom to abuse use this anywhere I please, I imagine this would be against a "fundamental tenet of type safety" or some such notion, not to mention probably causing a lot of issues with CFA and the like.

Moreover, if you look at the built-in "utility types" (such as: Exclude, Pick, ReturnType, etc) none of them really do any magic, they're just shortcuts over existing syntax and don't add any new behavior. For example,

type Exclude<T, U> = T extends U ? never : T;

Northing special there, just a quick shortcut. To my knowledge all of the utility types are like that, with (perhaps) the exception of the template literal types like Capitalize<...> So I would expect to see additional / altered syntax before a magic helper type like that. πŸ€·β€β™‚οΈ

@AlexGalays
Copy link

AlexGalays commented Jun 3, 2021

I could use that too.

With a lib similar to Immer that added some extra methods to the wrapped types you typically would need to type a proxy'ed object like this:

type Proxied<T extends Record<string, any>> = {
  get [ K in keyof T]: Proxied<T[K]>
  set [ K in keyof T]: T[K]
}

@pikax pikax changed the title Variant accessors for index signatures Variant accessors for index signatures and mapped types Jun 3, 2021
@kemsky
Copy link

kemsky commented Aug 30, 2021

Here is my case:

Getter does not return undefined, setter accepts undefined:

export interface IProperty<T> {
    get value(): T;
    set value(value: T | undefined);
}

// the following is ok with "strictNullChecks": true
const a: IProperty<string> = {value: ''};
a.value = undefined;
const b = a.value;
console.log(b);

Convert any type to accept undefined:

// this type works, but it also makes getter nullable :(
export type EditableObject<T> = {
    [K in keyof T]: EditableObject<T[K]> | undefined;
};

@ccheraa
Copy link

ccheraa commented Nov 6, 2021

Here's my use case:

I have a class Data, I can use an instance of it to query data by calling a function get that returns another Data object.
I can set data by calling set.
To read the data back, there's an async function read that returns any.
For example, I can do:

let data = new Data;
data.get('admin').get('settings').get('defaultPage').set('dashboard');
data.get('admin').get('settings').get('defaultPage').read()
  .then(page => console.log('the default page is:', page);

To type this, I wrapped it in a Proxy:

type Database = {
  admin: {
    settings: {
      defaultPage: string;
    }
  }
}

type DatabaseProxy<T> = {
  [k in keyof T]: DatabaseProxy<T[k]>;
} & {
  value: Promise<T>;
  set(value: T): void;
}

const databaseProxyHandler: ProxyHandler<any> = {
  get(target: any, prop: string) {
    if (prop === 'value') {
      return target.read();
    }
    if (prop === 'set') {
      return target.set.bind(target);
    }
    return new Proxy(target.get('prop'), databaseProxyHandler);
  },
  set(target: any, prop: string, value: any) {
    if (prop === 'value') {
      target.set(value);
      return true;
    }
    return false;
  },
};

const database = new Proxy<DatabaseProxy<Database>>(data.get('prop'), databaseProxyHandler);

The previous code will become:

database.admin.settings.defaultPage.set('dashboard');
database.admin.settings.defaultPage.value
  .then(page => console.log('the default page is:', page);

With variant accessors, I can change the DatabaseProxy definition to:

type DatabaseProxy<T> = {
  get [k in keyof T]: DatabaseProxy<T[k]>;
  set [k in keyof T]: T[k];
} & Promise<T>

And with some changes to the handler, my previous code would become:

database.admin.settings.defaultPage = 'dashboard';
database.admin.settings.defaultPage
  .then(page => console.log('the default page is:', page);

@thw0rted
Copy link

thw0rted commented Nov 8, 2021

I don't mean to be overly critical, but your API reads like your first language is Ruby. Proxy abuse like that doesn't seem like the kind of thing TS should be encouraging. What's wrong with database.get("admin.settings.defaultPage")? If you want to encode the database key hierarchy for type-checking, you can use conditional types to generate a union of template literal strings from an interface.

@ccheraa
Copy link

ccheraa commented Nov 9, 2021

I agree with you, my approach only benefits the developer by offering typing and cleaner code, but that's not the issue here.
My issue is having a setter that accepts a value of type X while the getter returns Promise<X>.

PS: you said:

you can use conditional types to generate a union of template literal strings from an interface

Is there a way to generate the type: 'admin.settings.defaultPage', and the others, from my previous interface?

@thw0rted
Copy link

thw0rted commented Nov 9, 2021

There are a bunch of issues that address your "PS" question, but I haven't seen a consistent name for the problem. I usually search with keywords like "deep object path" or "deep keyof", which turns up this sort of thing. Using the updated answer from @jcalz there, we get this result

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]

type Join<K, P> = K extends string | number ?
    P extends string | number ?
    `${K}${"" extends P ? "" : "."}${P}`
    : never : never;

type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: K extends string | number ?
        `${K}` | Join<K, Paths<T[K], Prev[D]>>
        : never
    }[keyof T] : ""

type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T] : "";


type Database = {
  admin: {
    settings: {
      defaultPage: string;
    }
  }
}

type DBKeys = Paths<Database>;

Note that type DBKeys = "admin" | "admin.settings" | "admin.settings.defaultPage". It's trickier, but still possible, to come up with a deep object path+value conditional type, such that database.get("admin.settings.defaultPage") returns a string but you could have another path whose value is a number, etc. If you really want to switch to that approach, I can try to dig up the implementation I'm using.

@ccheraa
Copy link

ccheraa commented Nov 9, 2021

Thank you for your example, this helps make my code much better.
But I still believe that being able to get a Promise and set a value will make it even better.

@wycats
Copy link

wycats commented Dec 2, 2021

I am in favor of this change, largely for the reasons already discussed above :)

@aurospire
Copy link

aurospire commented Aug 2, 2022

Really hoping this gets implemented, seems like a hole in the type system since we can now have getters/setters with different types.

As a simplified example, I'm trying to implement dynamically generated objects created from an object of function validators passed in to a function.

type Validator<O, I extends O> = (value: I) => O;

type ValidatorObject<V extends { [key: string]: Validator<any, any>; }> = {
    get [K in keyof Validators](): Validators[K] extends Validator<any, infer O> ? O : never  
    set [K in keyof Validators](value: Validators[K] extends Validator<infer I, any> ? I : never)
};

CraigMacomber added a commit to microsoft/FluidFramework that referenced this issue Mar 11, 2023
## Description

Improve typed schema system and add some initial schema aware API
generation.

One important note: the API we have for EditableTree can not be given
the desired schema aware strongly typed interface due to a missing
feature in typescript:
microsoft/TypeScript#43826 .
Thus this PR adds a a schema aware version of contextually typed node
data, and an API similar to (but unfinished) the EditableTree normalized
format for reading, but can not produce the API thats the intersection
of both allowing nice reading and flexible editing as that would require
the setters to be more tolerant that the getter types, which as noted
int he above issue is not supported by TypeScript.

## Breaking Changes

FieldSchema now takes a `FieldKindSpecifier` instead of an
`FieldKindIdentifier` so it is possible to use a FieldKind directly.

ContextuallyTypedNodeDataObject's typeName is no longer branded, making
it more concise for writing literals.
Since the schema aware APIs force the correct string content, and doing
that is a bit messy with branding, this seems like an improvement.
@7nik
Copy link

7nik commented Jul 23, 2023

I have a proxy that wraps values of properties but still allows assigning new values of the original type.

I thought I could type it properly because now, in 5.1, we can type getter and setter separately. But we still cannot use get/set on mapped types.

@CraigMacomber
Copy link

I have a proxy that wraps values of properties but still allows assigning new values of the original type.

I thought I could type it properly because now, in 5.1, we can type getter and setter separately. But we still cannot use get/set on mapped types.

After some testing, I'm also stuck on this. There are a lot of approaches that almost work, but I can't find one that actually works.

  • If & actually produced a type which allowed all interaction either type allowed, we could make this work.
  • If there is a way to preserve the set type when making a mapped type, it could work.
  • If there is a way to define a setter with a generic key type, it could work.
  • If mapped types supported a writeonly that generated setters, it could work.
  • If mapped types allowed a way to set the read and write types separately it could work.

Sadly, I can't find any way to leverage this new expressiveness of setters with mapped types: they simply lose the setter type no matter what you do.

This might be fixable now that TypeScript can actually represent the types we want to generate as of 5.1, but it looks like we will need an extension to mapped types if we want be able to use it.

@JesseRussell411
Copy link

I have a slightly stupid use case for this.

You ever try to set a property on an object that might be null? Like, if it's null, then nothing should happen, but if it isn't, then the property should be set.

Like this.

foo?.bar = 7

But you can't do that. I think it's a feature in dart, but this is javascript and the left hand side can't be optional.
So I wrote a little tool called nullset that looks like this

nullset(foo).bar = 7

basically nullset returns foo if foo is not null (or undefined) but if it is, it just returns {}, which is a, sort-of, sacrificial object that will have a field set, but then just get garbage collected later, so nothing really happens.

And what's really neat is that you can use it with optional chains like this:

nullset(foo?.bar?.biv?.baz).bim = 7

instead of doing this:

if (foo?.bar?.biv?.baz != null) foo?.bar?.biv?.baz.bim = 7

But there are two problems with this tool. One is the sacrificial object that gets returned when the source object is null, which is a small performance concern. And the other one is typing, which is why I'm bringing this up. I can't figure out a safe way to type this. I essentially want to make the return type equal to the input type (nullset(): T) but write-only (nullset(): Writeonly). Making it read-only is easy, but there is no write-only as far as I know. I can't just return T (nullset(): T) because then typescript could think that {} was actually T and the user could read properities off of it that typescript says are not null (or undefined) but that are actually undefined.

This feature would let me do this.

nullset<T>(obj: T | null | undefined): {
    set [P in keyof T](value: T[P]);
    get [P in keyof T](): T[P] | undefined;
}

but since I can't, in the meantime, I'm doing this.

nullset<T, F extends keyof T>(obj: T | null | undefined, field: F, value: T[F]): void

Which, actually fixes the performance concern too by not creating the sacrificial object, but I think it's just... less cool.

nullset(foo?.bar?.biv?.baz, "bim", 7)

vs

nullset(foo?.bar?.biv?.baz).bam = 7

@CraigMacomber
Copy link

My use case is that in Fluid Framework's new SharedTree, we provide a TypeScript powered schema system from which we generate APIs for the nodes. This allows us to generate a nice schema aware statically typed API for applications to use to work with their data, however we currently cannot support assignment to members of this tree in all cases due to this missing feature.

To be specific this code https://github.com/microsoft/FluidFramework/blob/0b5944050d3bc4470a87de4a4332235d37cb719c/experimental/dds/tree2/src/feature-libraries/editable-tree-2/editableTreeTypes.ts#L454-L466 implements the typing for our "struct" like nodes. Ideally it would generate getters and setters for each member based on the schema, but it is unable to do this. If we could replace -readonly with +writeonly to generate setters, then inserting new content into the tree could be done in an ergonomic and type safe way.

Our workaround for this is to provide factory functions that generate dummy node options which implement our whole node API (mostly with stubs) so you will be able to assign them, but if we could control the setter type correctly, we would not have to add all these stub methods that don't really work or make sense (ex: you can't get the parent or context of a node until its in the tree) and could instead make it properly type safe.

We really benefit from our schema system being directly in TypeScript: refactor rename, doc comments and go to definition on the struct fields just work, and take developers to the schema properly. This is really neat, and not something we could do practically with other approaches (for example doing ahead of time code generation of TypeScript files form schema). This assignment limitation is the one place we can't achieve the API we want this way.

Some more information about Fluid Framework and Shared tree can be found in: https://devblogs.microsoft.com/microsoft365dev/fluid-framework-2-0-alpha-is-now-available/

For details on SharedTrees schema aware API system, of which this is part can be found in https://github.com/microsoft/FluidFramework/tree/0b5944050d3bc4470a87de4a4332235d37cb719c/experimental/dds/tree2/src/feature-libraries/editable-tree-2

And some example usage of this API:

Right now, in that example, we can allow assigning to part.name since it's just a string. But if we replaced that with another struct, we would lose the ability to assign to it without introducing some kind of type unsafty unless we had this feature.

@Trakkasure
Copy link

Trakkasure commented Nov 29, 2023

I have a slightly stupid use case for this.
This is not a stupid use case, it is an interesting one to think about.

But you can't do that. I think it's a feature in dart, but this is javascript and the left hand side can't be optional. So I wrote a little tool called nullset that looks like this

nullset(foo).bar = 7

basically nullset returns foo if foo is not null (or undefined) but if it is, it just returns {}, which is a, sort-of, sacrificial object that will have a field set, but then just get garbage collected later, so nothing really happens.

And what's really neat is that you can use it with optional chains like this:

nullset(foo?.bar?.biv?.baz).bim = 7

instead of doing this:

if (foo?.bar?.biv?.baz != null) foo?.bar?.biv?.baz.bim = 7

But there are two problems with this tool. One is the sacrificial object that gets returned when the source object is null, which is a small performance concern. And the other one is typing, which is why I'm bringing this up. I can't figure out a safe way to type this. I essentially want to make the return type equal to the input type (nullset(): T) but write-only (nullset(): Writeonly).

In the case that you are presenting, is write-only a thing that is necessary?

A naive that I tried:

function nullset<T>(obj:T | null | undefined): T {
    return obj!==null && typeof obj == 'object' ? obj : ({} as T);
}
type TestType = {
    one?: {
        two?: { three?: number }
    }
};
let x:TestType = { one: { two: {}}};
let y:TestType = { one: {}};

nullset(x?.one?.two).three = 3;
nullset(y?.one?.two).three = 3;
const z = nullset(y?.one?.two).three = 3;

Assignment of z is improper use case and would be highlighted by many linters. If left unchanged it would set z to the number 3.

I'm sure I'm missing something in your explanation.

@nex3
Copy link

nex3 commented Oct 9, 2024

My use-case for this is pretty straightforward: I'm exposing an AST API with a Proxy over a Record<string, Expression> and I want to allow users to set strings that get automatically parsed into expressions, for example node.configuration.foo = "1 + 2". This works fine in plain JS, but is currently untypeable in TypeScript.

@Trakkasure
Copy link

My use-case for this is pretty straightforward: I'm exposing an AST API with a Proxy over a Record<string, Expression> and I want to allow users to set strings that get automatically parsed into expressions, for example node.configuration.foo = "1 + 2". This works fine in plain JS, but is currently untypeable in TypeScript.

can you expand on this with a simplified example. I see what is in quotes, but it is not enough to wrap my head around to understand what you mean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests