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

Represent the types of function parameters that mutate inside the function. #22865

Open
lilezek opened this issue Mar 25, 2018 · 9 comments
Open
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

@lilezek
Copy link

lilezek commented Mar 25, 2018

In JavaScript is possible to mutate objects inside functions. Right now, the following code in JavaScript:

function merge(x,y) {
  Object.assign(x,y);
} 

let x = {a: 1};   
merge(x, {b: 2});
console.log(x.b);

Can't be written in TypeScript without casting the type. There are a few options whose type definition is wrong in all scenarios I can think of (maybe I'm missing a better option):

Option 1

let x: {a: number, b: number} = {a: 1}; // Error, missing b
merge(x, {b: 2});

Option 2

let x: {a: number, b: number} = {a: 1, b: 2};
merge(x, {b: null});
// From here, x.b is not a number anymore, but you could do
let y: number= x.b;

Suggestion

There could be an extension to function parameter definition like the following:

// then keyword indicates that before it can be type A, and after it will be of type A&B.
function merge<A,B>(x: A then x2: A&B, y: B) {
  Object.assign(x2,y);
}

let x: {a: number, b: number} = {a: 1, b: 2};
merge(x, {b: null});
// Here, type of x is {a: number, b: number} & {b: null}
x.b; // Type null

There, we indicate that whatever type was x before, now it is something different. The code above could be written in TypeScript as follows:

// then keyword indicates that before it can be type A, and after it will be of type A&B.
function merge<A,B>(x: A, y: B) {
  Object.assign(x,y);
}

let x: {a: number, b: number} = {a: 1, b: 2};
merge(x, {b: null});
// Here, type of x is {a: number, b: number} & {b: null}
let xAfterMerge = x as {a: number, b: number} & {b: null};
// Since this line, x should not be used but xAfterMerge
xAfterMerge.b; // Type null

Another example

interface Before {
  address: string;
}

interface After {
  addr: string;
}

function map(userb: Before then usera: After) {
   usera.addr = userb.address;
   delete userb.address;
}

let u = {adress: "my street"};
map(u);
console.log(u.addr);

That could be syntax sugar for this:

interface Before {
  address: string;
}

interface After {
  addr: string;
}

function map(userb: any) {
   userb.addr = userb.address;
   delete userb.address;
}

let u = {adress: "my street"};
map(u); 
console.log((u as After).addr);

Syntax

It could be something like:

identifier: type *then* identifier: type  

With the identifiers being different, and with the types being mandatory an extension of Object.

@lilezek lilezek changed the title Input and output types for function parameters. Represent the types of function parameters that mutate inside the function. Mar 25, 2018
@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Mar 26, 2018
@mhegazy
Copy link
Contributor

mhegazy commented Mar 27, 2018

Modelling the behavior of merge and object.assign is tracked by #13288.

I am not sure we can model side effects in any useful way throughout the system, nor do we want to try.

@kpdonn
Copy link
Contributor

kpdonn commented Mar 27, 2018

I can't comment on the feasibility of this idea but I know I have wanted it before. I like creating type-safe builder style interfaces in typescript but currently the only way I know of to avoid losing the types is to make sure you always chain the builder calls and if you need to store the intermediate stages of a builder, always ensure it is stored in a fresh variable. Example:

interface Builder<Props = {}> {
  addProp<T extends string, V>(prop: T, val: V): Builder<Props & Record<T, V>>
  finish(): Props
}
declare function newBuilder(): Builder

const builderA = newBuilder()
builderA.addProp("a", "mystring")
builderA.addProp("b", 123)
const resultA = builderA.finish() // unfortunately resultA has type {}

let builderB = newBuilder()
builderB = builderB.addProp("a", "mystring")
builderB = builderB.addProp("b", 123)
const resultB = builderB.finish() // resultB also has type {}

const builderC1 = newBuilder()
const builderC2 = builderC1.addProp("a", "mystring")
const builderC3 = builderC2.addProp("b", 123)
const resultC = builderC3.finish() // Now has type {a: string, b: number} as desired

const resultD = newBuilder()
  .addProp("a", "mystring")
  .addProp("b", 123)
  .finish() // also has type {a: string, b: number} as desired

In a perfect world it'd be nice if there was some way to model stateful changes like builderA. I mostly get around it by making the builders immutable so you have to chain or reassign the variable to use it at all, however the builderB case still causes problems with the types even then.

@lilezek
Copy link
Author

lilezek commented Mar 27, 2018

I updated with a new example and a little bit more detailed explanation.

@jack-williams
Copy link
Collaborator

@kpdonn I think what you want is support for linear / affine types, you're basically encoding it by hand currently.

@lilezek
Copy link
Author

lilezek commented May 7, 2018

Right now some kind of context-sensitive types (I don't know the exact name of this feature) is already present in the system. For instance:

let x: Array<any> | number;
...
if (x instanceof Array) {
   // here x changes from Array|number to Array
} else {
   // here x changes from Array|number to number
}

Couldn't that feature be used for this new purpose?

@Olian04
Copy link

Olian04 commented Aug 5, 2018

Another way of implementing this could be by adding a keyword that suggests that the function will modify the type of a parameter. We could borrow the out keyword from C#.

function Special(out obj) {
  obj['foo'] = () => {};
}

const bar = {};
Special(out bar);
bar.foo(); // ts-ok, js-ok

In the function scope an out argument should be considered a const value but not an immutable value.

function Fail(out obj) {
  obj = {}; // ts-error assigning to const
}

let obj = {};
Fail(out obj);

The out keyword is NOT optional.

function Fail(out obj) {}

let obj = {};
Fail(obj); // ts-error overload error, no overload takes a single "none out" argument

We could then explicitly type the out argument with the previously purposed then keyword.

function Special<T>(out obj: T then T & { foo(): void } ) {
  obj['foo'] = () => {};
}

const bar = {};
Special(out bar);
bar.foo(); // ts-ok, js-ok

I would just like to mention that you can achieve something close to this by "abusing" the typegurad feature.

function Special<T>(obj: T): obj is T & { foo(): void; } {
  obj['foo'] = () => {};
  return true;
}

const bar = {};
if (!Special(bar)) throw 'Will never happen';
bar.foo(); // ts-ok, js-ok

@rossjrw
Copy link

rossjrw commented Aug 11, 2020

I've been sorely missing this 'feature' in a project of mine, which is a refactor of an old JS project where the primary mechanism of data transfer is an object that is passed from function to function and mutated in each. Typing that has been a nightmare to the point where I've found the easiest solution is to declare a type with all possible added properties marked as optional. My code is now filled with non-null assertions, but I've concluded that Typescript just isn't designed for this. Object mutation goes against the spirit of static types.

@jo3-l
Copy link

jo3-l commented Nov 10, 2020

I can't comment on the feasibility of this idea but I know I have wanted it before. I like creating type-safe builder style interfaces in typescript but currently the only way I know of to avoid losing the types is to make sure you always chain the builder calls and if you need to store the intermediate stages of a builder, always ensure it is stored in a fresh variable. Example:

interface Builder<Props = {}> {
  addProp<T extends string, V>(prop: T, val: V): Builder<Props & Record<T, V>>
  finish(): Props
}
declare function newBuilder(): Builder

const builderA = newBuilder()
builderA.addProp("a", "mystring")
builderA.addProp("b", 123)
const resultA = builderA.finish() // unfortunately resultA has type {}

let builderB = newBuilder()
builderB = builderB.addProp("a", "mystring")
builderB = builderB.addProp("b", 123)
const resultB = builderB.finish() // resultB also has type {}

const builderC1 = newBuilder()
const builderC2 = builderC1.addProp("a", "mystring")
const builderC3 = builderC2.addProp("b", 123)
const resultC = builderC3.finish() // Now has type {a: string, b: number} as desired

const resultD = newBuilder()
  .addProp("a", "mystring")
  .addProp("b", 123)
  .finish() // also has type {a: string, b: number} as desired

In a perfect world it'd be nice if there was some way to model stateful changes like builderA. I mostly get around it by making the builders immutable so you have to chain or reassign the variable to use it at all, however the builderB case still causes problems with the types even then.

Just encountered this issue as well (typing stateful builders), rather disappointed to find out that it's not really possible to do so without chaining the calls together. Does seem unlikely that this will be supported in TypeScript anytime soon as the original issue mutating function parameters is mostly covered by the asserts keyword. In case anyone's searching for this, here's the original code used as an example in this issue rewritten to type-check correctly:

function merge<T>(x: unknown, y: T): asserts x is (typeof x) & T {
  Object.assign(x, y);
}

const x = { a: 1 };   
merge(x, { b: 2 });
// No error now!
console.log(x.b);

@jsejcksn
Copy link

jsejcksn commented Jul 10, 2023

Related issues:

About a year ago I encountered the need for this, but wasn't motivated to engage at the time. I recently considered opening a new issue to cover a specific angle of this (return type), but I can't justify doing that yet — at this point, I think this issue is on the same topic.

I'd like to see a new kind of return type: one that asserts the mutation of a mutable argument — and also returns that value. Essentially, this is an assertion function which — instead of returning void — returns one of its parameters, but it can arbitrarily mutate that parameter (just like in JavaScript), assigning the new type to it.

In the example below, both obj and theSameObjAfterMutation should have object identity, and both should be of the type { bar: string } after invocation of the function with the proposed return type. In the code shown, this is obviously not the case because this feature doesn't exist and it doesn't use the proposed return type, which I also haven't described yet:

Playground

function assertDeletesFooPropAndAddsBarProp<T extends object>(
  obj: T,
): asserts obj is Omit<T, "foo"> & { bar: string } { /*
                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A type predicate's type must be assignable to its parameter's type.
  Type 'Omit<T, "foo"> & { bar: string; }' is not assignable to type 'T'.
    'Omit<T, "foo"> & { bar: string; }' is assignable to the constraint of type 'T',
    but 'T' could be instantiated with a different subtype of constraint 'object'.(2677) */

  delete (obj as { foo?: unknown }).foo;
  (obj as { bar: string }).bar = "bar";
  return obj; /*
  ~~~~~~
Type 'T' is not assignable to type 'void'.
  Type 'object' is not assignable to type 'void'.(2322) */
}

const obj = { foo: "foo" };
    //^? const obj: { foo: string; }

const theSameObjAfterMutation = assertDeletesFooPropAndAddsBarProp(obj);
    //^? const theSameObjAfterMutation: void

obj;
//^? const obj: { foo: string; }

Perhaps the return type could be something like this, which uses as in place of is:

function assertDeletesFooPropAndAddsBarProp<T extends object>(
  obj: T,
): asserts obj as Omit<T, "foo"> & { bar: string };

But don't let that suggestion hinder reception of this message — this is about the concept, not bikeshedding syntax.

This could be used to solve #32253, among other mutations.

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

9 participants