Skip to content

// @ts-mutate decorator/directiveΒ #61179

Open

Description

πŸ” Search Terms

mutate type, alter type, post-declaration narrow

βœ… Viability Checklist

⭐ Suggestion

When doing property assignment to a variable already typed, allow a // @ts-mutate above the assignment which will

  • A) Not throw an error trying to assign to a non-existent property
  • B) Further narrow the type, encompassing the change

Example:

interface ObjectType {
  bar: string
}

const foo: ObjectType = { bar: 'hello' }

// @ts-mutate
foo.baz = 'hello'

foo
// ^? -- this is now `ObjectType & { baz: string }`

πŸ“ƒ Motivating Example

One specific example of why this would be useful would be a query constructor for type inference. There are GraphQL query constructors out there (GenQL) that query by supplying an object. I've built functions that yield reusable queries but sometimes I want to extend them.

This would allow me to take an existing query object and add an additional field to query, keeping my strict type inference.

πŸ’» Use Cases

  1. What do you want to use this for?

This could be used for constructing large objects sequentially and with inference without having to basically duplicate the information

  1. What shortcomings exist with current approaches?

If you have an existing variable, you have to alter its type declaration with the type that it is (explicitly) and intersect it with your changes. This also doesn't necessarily narrow correctly. There's no exisiting clean, easy way to do this that works off of type inference and narrowing

  1. What workarounds are you using in the meantime?
     // UGLY
     const query: ReturnType<typeof queryProp> & { prop: { addition?: true } } = queryProp(...args)
     query.prop.addition = true

Activity

  1. shicks commented on Feb 14, 2025

    @shicks
    Contributor

    A slightly nicer workaround (if you're using an optimizer that can inline the empty function, or else don't care about a few extra bytes) is

    function addProp<const K extends string, V>(arg: unknown): asserts arg is {[K1 in K]: V} {}
    addProp<'baz', string>(foo);

    (playground link)

  2. changed the title [-]`// @mutate` decorator[/-] [+]`// @ts-mutate` decorator/directive[/+] on Feb 14, 2025
  3. snarbles2 commented on Feb 14, 2025

    @snarbles2

    I think I'd prefer a keyword or helper type, but I'd take this in any form.

    const foo: ObjectType & mut = ... //or whatever keyword people won't fight over
    const foo: Mutate<ObjectType> = ...

    This feature request is kind of a narrower version of an idea I've had the idea bouncing around my head for a while. It would be nice to have some kind of "object construction in process" type.

    // typed {status: 'error', errorCode: number} | {status: 'success'}
    // or just {status: string, errorCode?: number}
    const result = (() => {
      var x: Mutate<{}> = {}; 
      if(someCondition) {
        x.status = 'error';
        x.errorCode = 1234;
      } else {
        x.status = 'success';
      }
    
      return x;
    }) ();
  4. exoRift commented on Feb 14, 2025

    @exoRift
    Author

    I think I'd prefer a keyword or helper type, but I'd take this in any form.

    const foo: ObjectType & mut = ... //or whatever keyword people won't fight over
    const foo: Mutate<ObjectType> = ...

    This feature request is kind of a narrower version of an idea I've had the idea bouncing around my head for a while. It would be nice to have some kind of "object construction in process" type.

    // typed {status: 'error', errorCode: number} | {status: 'success'}
    // or just {status: string, errorCode?: number}
    const result = (() => {
      var x: Mutate<{}> = {}; 
      if(someCondition) {
        x.status = 'error';
        x.errorCode = 1234;
      } else {
        x.status = 'success';
      }
    
      return x;
    }) ();

    A keyword/helper type could be a nicer syntactic sugar, but that would require compiler changes and possibly break existing code. A directive would be the simplest solution for a feature that should definitely be a part of Typescript, in my opinion.

  5. shicks commented on Feb 14, 2025

    @shicks
    Contributor

    Refining my earlier suggestion (playground).

    function setProp<const K extends string|symbol, V>(arg: unknown, key: K, val: V): asserts arg is {[_ in K]: V} {
      (arg as any)[key] = val;
    }
    
    const obj = {};
    setProp(obj, 'foo', 42);
    console.dir(obj);
    //          ^? const obj: { foo: number; }

    I get that it's not particularly popular, but most optimizers can eliminate the function entirely (specifically, uglify and terser had no problem, though esbuild was less smart). The fact that it's quite doable without any new syntax suggests suggests to me that the benefit doesn't outweigh the cost.

  6. exoRift commented on Feb 15, 2025

    @exoRift
    Author

    Refining my earlier suggestion (playground).

    function setProp<const K extends string|symbol, V>(arg: unknown, key: K, val: V): asserts arg is {[_ in K]: V} {
      (arg as any)[key] = val;
    }
    
    const obj = {};
    setProp(obj, 'foo', 42);
    console.dir(obj);
    //          ^? const obj: { foo: number; }

    I get that it's not particularly popular, but most optimizers can eliminate the function entirely (specifically, uglify and terser had no problem, though esbuild was less smart). The fact that it's quite doable without any new syntax suggests suggests to me that the benefit doesn't outweigh the cost.

    What would the cost be?

    I dislike your workaround because it requires runtime code for something that's purely typed. Although some bundlers optimize it out, that should not be relied on.

  7. snarbles2 commented on Feb 17, 2025

    @snarbles2

    A keyword/helper type could be a nicer syntactic sugar, but that would require compiler changes and possibly break existing code. A directive would be the simplest solution for a feature that should definitely be a part of Typescript, in my opinion.

    It requires compiler changes either way, unless you intend to pair a //@ts-ignore with each //@mutate or otherwise ignore the errors when your expected type mutations don't occur. Isn't new type syntax in line with how typescript has implemented these sorts of features in the past?

  8. exoRift commented on Feb 17, 2025

    @exoRift
    Author

    A keyword/helper type could be a nicer syntactic sugar, but that would require compiler changes and possibly break existing code. A directive would be the simplest solution for a feature that should definitely be a part of Typescript, in my opinion.

    It requires compiler changes either way, unless you intend to pair a //@ts-ignore with each //@mutate or otherwise ignore the errors when your expected type mutations don't occur. Isn't new type syntax in line with how typescript has implemented these sorts of features in the past?

    In the past, yeah. Now TS is moving towards a strictly strippable instruction set like with their new flag that prevents things like enums.

    I meant compiler changes as in one less thing it has to strip. A comment doesn't have any bearing on the code by default, so the only changes needed are to the type checker, not the system that transpiles.

  9. snarbles2 commented on Feb 17, 2025

    @snarbles2

    In the past, yeah. Now TS is moving towards a strictly strippable instruction set like with their new flag that prevents things like enums.

    I don't think a new helper type or type-like keyword impacts this one way or another. Type annotations are that "strippable" syntax and aren't going away, and that's where a "mutable" pseudo-type would live.

  10. jcready commented on Feb 18, 2025

    @jcready

    This is where flow's comment syntax was incredibly useful. You could very well define (and "invoke") functions like the aforementioned suggestion all inside comments thus leading to zero runtime overhead while still performing the desired refinement.

    /*::
    function addProp<const K extends string, V>(arg: unknown): asserts arg is {[K1 in K]: V} {}
    */
    
    interface ObjectType {
      bar: string
    }
    
    const foo: ObjectType = { bar: 'hello' }
    
    /*:: addProp<'baz', string>(foo); */
    
    foo.baz = 'hello'
    
    foo
    // ^? -- this is now `ObjectType & { baz: string }`
  11. shicks commented on Feb 18, 2025

    @shicks
    Contributor

    I get that it's not particularly popular, but most optimizers can eliminate the function entirely (specifically, uglify and terser had no problem, though esbuild was less smart). The fact that it's quite doable without any new syntax suggests suggests to me that the benefit doesn't outweigh the cost.

    What would the cost be?

    I'm not finding it anymore, but I recall seeing a post from the TypeScript maintainers that they consider new features as something like "starting at -1000" compared to not adding them, since every new feature adds both cognitive and implementation overhead and complexity.

    In this case, when there's a reasonably viable workaround using existing features, that decreases the upside quite a bit.

    I dislike your workaround because it requires runtime code for something that's purely typed. Although some bundlers optimize it out, that should not be relied on.

    I don't understand this argument. It seems to come from a point of view of caring so much about performance that you can't tolerate an extra trivial no-op function call (that the JIT will probably optimize away anyway) but not enough to use a proper optimizer? Which seems like it should be an empty intersection.

  12. exoRift commented on Mar 3, 2025

    @exoRift
    Author

    Stephen Hicks (@shicks) I actually tried your method and it doesn't work as the assertion doesn't cross the function return inference past the first depth. Technically, you could provide the path as a string and do string splitting on . and all that dynamic jazz but then there goes any hope of inline optimization

    Playground

  13. shicks commented on Mar 3, 2025

    @shicks
    Contributor

    @shicks I actually tried your method and it doesn't work as the assertion doesn't cross the function return inference past the first depth. Technically, you could provide the path as a string and do string splitting on . and all that dynamic jazz but then there goes any hope of inline optimization

    Playground

    If you're talking about having this work on nested properties, then it's already a whole different ballgame. For instance, ordinary asserts arg is T also can't handle that situation: playground. I also wouldn't expect the above-suggested Mutate<> solution to work for that, since you'd really need the property's value type to be Mutate rather than expecting it to apply recursively (I can imagine plenty of cases where you don't want or expect it to act recursively). The // @ts-mutate annotation suggestion is less of a non-starter at least here, but it's a lot less appealing for other reasons. As you mentioned, you can still achieve this generally by passing an array of property names, or else with a bunch of different trivially-inlineable functions for each of the different nesting counts. But that's also getting pretty ugly.

    I think my point here is that there's no clear winner.

  14. jendrikw commented on Oct 21, 2025

    @jendrikw

    I use Object.assign in these situations. Downside is that you can't reuse the same variable name.

    const foo2: ObjectType & {baz: string} = Object.assign(foo, {baz: 'hello'});
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions