-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Comments
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 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 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 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 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 |
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. |
Should this issue be renamed to "variant accessors for index signatures and mapped types"? |
I had the same thought about some magic built-in type such as type FooBar = GetterSetter<'Foo', 'Foo' | 'Bar'>;
let foo: FooBar;
foo = 'Foo'; // β
typeof foo === 'Foo'
foo = 'Bar'; // βtypeof foo === 'Foo' Assuming an unrestricted Moreover, if you look at the built-in "utility types" (such as: 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 |
I could use that too. With a lib similar to type Proxied<T extends Record<string, any>> = {
get [ K in keyof T]: Proxied<T[K]>
set [ K in keyof T]: T[K]
} |
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;
}; |
Here's my use case: I have a class 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 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 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); |
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 |
I agree with you, my approach only benefits the developer by offering typing and cleaner code, but that's not the issue here. PS: you said:
Is there a way to generate the type: |
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 |
Thank you for your example, this helps make my code much better. |
I am in favor of this change, largely for the reasons already discussed above :) |
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)
}; |
## 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.
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.
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. |
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.
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.
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:
instead of doing this:
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.
but since I can't, in the meantime, I'm doing this.
Which, actually fixes the performance concern too by not creating the sacrificial object, but I think it's just... less cool.
vs
|
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 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 |
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. |
My use-case for this is pretty straightforward: I'm exposing an AST API with a |
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. |
Suggestion
π Search Terms
variant accessors index signatures record
β Viability Checklist
My suggestion meets these guidelines:
β Suggestion
Support for having Variant Accessors with index signature.
π Motivating Example
π» Use Cases
The use case for this feature is vue 3 reactivity.
In Vue 3 there's
Ref<T>
(simplified as{ value: T}
) andReactive<UnwrappedRef<T>>
(unwraps{value: T}
toT
, recursive)This feature would allow us to generate a type which can accept a
Ref<T> | T
Basically:
On a
reactive
/ref
object the set in typescript must beUnwrappedRef<T>
which is not true, because when you assign to areactive
it will unwrap the value:The text was updated successfully, but these errors were encountered: