Repository navigation
Variant accessors for index signatures and mapped types #43826
Description
Activity
- addedAwaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScriptAn idea for TypeScript
on Apr 26, 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(orRecord[], 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
getandsetthat are both functions. Even though I only want to change the"settable"type of a subset of properties, Carlos Rodrigues (@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
OwnerRecordwhere each key is the name of a dog and each property was aDogRecordthis 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 Carlos Rodrigues (@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..
Reacted by Daniel Bronshtein, David and Samuel BraunOne interesting thing I noticed in Carlos Rodrigues (@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
GetterSetterwould 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 fewget/setReacted by Alexander Turtsevich, Matt W, James Sinclair and Samuel Braunjustin-schroeder commented
on May 27, 2021 More actionsWould be hugely useful. I actually just opened an SO post today specifically around this issue for pretty much the same reason Carlos Rodrigues (@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"?
Reacted by James Bromwell, Patrick Lienau and Samuel BraunHaving the special type
GetterSetterwould 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 fewget/setI 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 liketype 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 toabuseuse 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. 🤷♂️I could use that too.
With a lib similar to
Immerthat 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] }
- changed the title
[-]Variant accessors for index signatures[/-][+]Variant accessors for index signatures and mapped types[/+]on Jun 3, 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; };
Reacted by Ulrich-Matthias Schäfer, Cole Lawrence and Samuel BraunHere's my use case:
I have a class
Data, I can use an instance of it to query data by calling a functiongetthat returns anotherDataobject.
I can set data by callingset.
To read the data back, there's an async functionreadthat returnsany.
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
DatabaseProxydefinition 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);
Reacted by ExE Boss and Samuel BraunI 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.Reacted by Eshin Griffith, Justin Schroeder, aurospire, Jordan Tucker, Matt W, insidewhy, Benjamin Dobell and Samuel BraunI 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 typeXwhile the getter returnsPromise<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?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 Joe Calzaretta (@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 thatdatabase.get("admin.settings.defaultPage")returns astringbut you could have another path whose value is anumber, etc. If you really want to switch to that approach, I can try to dig up the implementation I'm using.Reacted by Charafeddine CheraaThank 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.Reacted by ExE Boss, aurospire and JesseRussell4112 remaining items
- added a commit that references this issue
on Mar 11, 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 commented
on Aug 10, 2023 More actionsI 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
settype 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
writeonlythat 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.
Reacted by Simon Sarris, Noah Encke, yann-achard-MS, Joshua Smithrud, JesseRussell411, Daniel Lehenbauer, Yannick Vessaz, Jason Hartman, Matt W, logitprop and 1 more- If
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 = 7But 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 thisnullset(foo).bar = 7basically 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 = 7instead of doing this:
if (foo?.bar?.biv?.baz != null) foo?.bar?.biv?.baz.bim = 7But 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]): voidWhich, 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 = 7CraigMacomber commented
on Oct 18, 2023 More actionsMy 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
-readonlywith+writeonlyto 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:
- Schema declaration for a struct node: https://github.com/microsoft/FluidFramework/blob/0b5944050d3bc4470a87de4a4332235d37cb719c/examples/data-objects/inventory-app/src/schema.ts#L10
- https://github.com/microsoft/FluidFramework/blob/0b5944050d3bc4470a87de4a4332235d37cb719c/examples/data-objects/inventory-app/src/view/inventoryList.tsx#L20
Right now, in that example, we can allow assigning to
part.namesince 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.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 = 7But 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.
My use-case for this is pretty straightforward: I'm exposing an AST API with a
Proxyover aRecord<string, Expression>and I want to allow users to setstrings that get automatically parsed into expressions, for examplenode.configuration.foo = "1 + 2". This works fine in plain JS, but is currently untypeable in TypeScript.Reacted by Andrew StanishMy use-case for this is pretty straightforward: I'm exposing an AST API with a
Proxyover aRecord<string, Expression>and I want to allow users to setstrings that get automatically parsed into expressions, for examplenode.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.
- added 2 commits that reference this issue
on Nov 24, 2024 - added a commit that references this issue
on Apr 2, 2025 Hopefully better late than never, here is an example (also on TS playground):
type FieldTypeMapping = { stringType: { output: string; input: string; }; numberType: { output: number input: string | number; }; }; type AllowedTypes = keyof FieldTypeMapping; type InputType<T extends AllowedTypes> = FieldTypeMapping[T]['input']; type OutputType<T extends AllowedTypes> = FieldTypeMapping[T]['output']; type FieldConfig = { [key: string]: AllowedTypes; }; const fieldConfig = { foo: 'stringType', bar: 'numberType', } as const satisfies FieldConfig; class DesiredGeneratedClass { _foo: string = ''; _bar: number = 0; get foo(): string { return this._foo; } set foo(value: string) { this._foo = value; } // potential problem: if number is not specified as the return type here, // .bar is inferred as a string on line 60 despite being a number get bar(): number { return this._bar; } set bar(value: string | number) { if (typeof value === 'string') { this._bar = parseFloat(value); } else { this._bar = value; } } } const myInstance = new DesiredGeneratedClass(); myInstance.foo = ''; // okay myInstance.foo; // string myInstance.bar = 2; // okay myInstance.bar = '2'; // okay myInstance.bar; // number <-- LINE 60 type GetClassType1<C extends FieldConfig> = { [K in keyof C]: InputType<C[K]>; } // matches desired setters, but not the getters type GeneratedClass1 = GetClassType1<typeof fieldConfig>; type GetClassType2<C extends FieldConfig> = { [K in keyof C]: OutputType<C[K]>; } // matches desired getters, but not the setters type GeneratedClass2 = GetClassType2<typeof fieldConfig>; /* // desired: type GetClassType<C extends FieldConfig> = { get [K in keyof C]: OutputType<C[K]>; set [K in keyof C]: InputType<C[K]>; } */
This example very roughly matches what I am trying to accomplish, for an ECS library where there's a function that dynamically generates a class based on some config. I've changed the structure a bunch from what it actually is, to demonstrate the problem more clearly, so I'm not looking for alternatives to the above structure because they wouldn't help in my actual situation, but having the desired solution at the end (or an equivalent) would solve the issue. Thank you!
- added a commit that references this issue
on Aug 8, 2026
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> | TBasically:
On a
reactive/refobject the set in typescript must beUnwrappedRef<T>which is not true, because when you assign to areactiveit will unwrap the value: