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

Add pure and immutable keywords to ensure code has no unintended side-effects #17181

Open
bradzacher opened this issue Jul 14, 2017 · 19 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@bradzacher
Copy link
Contributor

bradzacher commented Jul 14, 2017

The aim of this proposal is to add some immutability and pure checking into the typescript compiler. The proposal adds two new keywords that would give developers a means to define functions that are pure - meaning that the function has no-side effects, and define variables that are immutable - meaning that they can never be used in an impure context.

Pure

The pure keyword is used to define a function with no side-effects (it is allowed in the same places as the async keyword).

pure function x(arg) {
    return arg
}

The keyword should be not be emitted into compiled javascript code.

A pure function:

  • May not call an function not tagged as pure in the context of arguments, or this.
    • this.nonPure(), arg.nonPure(), nonPure(arg), and nonPure(this) are all disallowed.
  • May make non-pure calls on instance variables, as the non-pureness applies to instance variables, so the function is technically still side-effect free:
pure function x() {
    const arr = []
    arr.push(1)
    return arr
}
    • this one i'm not entirely sure about, but it seems like it would be very hard to build pure functions without it.
    • languages like elm get around this by having a push function which returns a new array.
  • Modify the input variables
    • arg.x = 1 is disallowed within the function body.
  • Modify variables on this
    • this.y = 1 is disallowed within the function body.
  • Must return a value (otherwise there's no point to the function!).

Immutable

Similarly a variable may be tagged as immutable:
immutable x = [] (maybe keyword should be shortened to immut, or the pure keyword could be reused for consistency?).
This keyword is replaced with const in emitted code.

An immutable variable:

  • Is treated as if it were const (i.e. its reference may not be reassigned).
  • May not have any impure instance methods called on it.
    • x.nonPure() is disallowed.
  • May not be passed as an argument to impure functions.
    • nonPure(x) is disallowed.
  • May not be reassigned to a variable reference that is also not marked as pure.
    • const y = x; is disallowed.
    • const y = { z: x } is disallowed.
  • May not have instance properties set on it.
    • x.foo = 1 is disallowed.
  • May be passed to pure functions.
    • pureFn(x) is allowed.
  • May have pure instance methods called on it.
    • x.toString() is allowed.
  • For arrays, its type is strictly set at definition time, meaning that element-wise type checks will pass (fixes: Type checking element typed arrays #16389)
    • i.e. the following code will now pass
pure function fn(arg : ('a' | 'b')[]) { }

immutable x = ['a', 'b']
fn(x)

With objects/interfaces

The keyword(s) should also be allowed in object (and by extension interface) definitions:

const obj1 = {
    // immutable and non-pure
    immutable fn1: function () { },
    
    // mutable and pure
    fn2: pure function () { },

    // immutable and pure
    immutable fn3: pure function () { },

    // immutable and non-pure
    immutable fn3() { },

    // immutable and pure
    pure fn3() { },
}

interface IFace {
    pure toString() : string // pure must always return a value

    immutable prop : number

    immutable pure frozenFn() : boolean
}

With existing typings

With this proposal, the base javascript typings could be updated to support it.
I.e. the array interface would become:

interface Array<T> {
    pure toString(): string;
    pure toLocaleString(): string;
    pure concat(...items: T[][]): T[];
    pure concat(...items: (T | T[])[]): T[];
    pure join(separator?: string): string;
    pure indexOf(searchElement: T, fromIndex?: number): number;
    pure lastIndexOf(searchElement: T, fromIndex?: number): number;
    pure every(callbackfn: (this: void, value: T, index: number, array: T[]) => boolean): boolean;
    pure every(callbackfn: (this: void, value: T, index: number, array: T[]) => boolean, thisArg: undefined): boolean;
    pure every<Z>(callbackfn: (this: Z, value: T, index: number, array: T[]) => boolean, thisArg: Z): boolean;
    pure some(callbackfn: (this: void, value: T, index: number, array: T[]) => boolean): boolean;
    pure some(callbackfn: (this: void, value: T, index: number, array: T[]) => boolean, thisArg: undefined): boolean;
    pure some<Z>(callbackfn: (this: Z, value: T, index: number, array: T[]) => boolean, thisArg: Z): boolean;
    pure forEach(callbackfn: (this: void, value: T, index: number, array: T[]) => void): void;
    pure forEach(callbackfn: (this: void, value: T, index: number, array: T[]) => void, thisArg: undefined): void;
    pure forEach<Z>(callbackfn: (this: Z, value: T, index: number, array: T[]) => void, thisArg: Z): void;
    pure map<U>(this: [T, T, T, T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U): [U, U, U, U, U];
    pure map<U>(this: [T, T, T, T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U, thisArg: undefined): [U, U, U, U, U];
    pure map<Z, U>(this: [T, T, T, T, T], callbackfn: (this: Z, value: T, index: number, array: T[]) => U, thisArg: Z): [U, U, U, U, U];
    pure map<U>(this: [T, T, T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U): [U, U, U, U];
    pure map<U>(this: [T, T, T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U, thisArg: undefined): [U, U, U, U];
    pure map<Z, U>(this: [T, T, T, T], callbackfn: (this: Z, value: T, index: number, array: T[]) => U, thisArg: Z): [U, U, U, U];
    pure map<U>(this: [T, T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U): [U, U, U];
    pure map<U>(this: [T, T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U, thisArg: undefined): [U, U, U];
    pure map<Z, U>(this: [T, T, T], callbackfn: (this: Z, value: T, index: number, array: T[]) => U, thisArg: Z): [U, U, U];
    pure map<U>(this: [T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U): [U, U];
    pure map<U>(this: [T, T], callbackfn: (this: void, value: T, index: number, array: T[]) => U, thisArg: undefined): [U, U];
    pure map<Z, U>(this: [T, T], callbackfn: (this: Z, value: T, index: number, array: T[]) => U, thisArg: Z): [U, U];
    pure map<U>(callbackfn: (this: void, value: T, index: number, array: T[]) => U): U[];
    pure map<U>(callbackfn: (this: void, value: T, index: number, array: T[]) => U, thisArg: undefined): U[];
    pure map<Z, U>(callbackfn: (this: Z, value: T, index: number, array: T[]) => U, thisArg: Z): U[];
    pure filter(callbackfn: (this: void, value: T, index: number, array: T[]) => any): T[];
    pure filter(callbackfn: (this: void, value: T, index: number, array: T[]) => any, thisArg: undefined): T[];
    pure filter<Z>(callbackfn: (this: Z, value: T, index: number, array: T[]) => any, thisArg: Z): T[];
    pure reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue?: T): T;
    pure reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    pure reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue?: T): T;
    pure reduceRight<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;

    push(...items: T[]): number;
    pop(): T | undefined;
    reverse(): T[];
    shift(): T | undefined;
    slice(start?: number, end?: number): T[];
    sort(compareFn?: (a: T, b: T) => number): this;
    splice(start: number, deleteCount?: number): T[];
    splice(start: number, deleteCount: number, ...items: T[]): T[];
    unshift(...items: T[]): number;

    [n: number]: T;
}
@bradzacher bradzacher changed the title Add "pure" keyword to ensure a function has no unintended side-effects Add pure and immutable keywords to ensure code has no unintended side-effects Jul 14, 2017
@kitsonk
Copy link
Contributor

kitsonk commented Jul 14, 2017

Duplicate of #13721
Duplicate of #6532

Adding expression level syntax is anti pattern for TypeScript. These have other approaches which are erasable or provide sufficient downstream meta data for further optimizations.

@kitsonk kitsonk marked this as a duplicate of #13721 Jul 14, 2017
@kitsonk kitsonk marked this as a duplicate of #6532 Jul 14, 2017
@bradzacher
Copy link
Contributor Author

How is it a duplicate of #13721? That issue is entirely about adding a comment to emitted code so that uglyifyjs can optimise it away?
This is about adding features to the typescript language, pre compilation. In fact it emits no different code.

Similarly for #6532, that issue only pertains to reference assignment. You can still mess with the underlying object, which is the entire problem that I am attempting to solve here.

The problem is that there is no way to do compile time checks to ensure that an object has not been modified (which causes issues such as #16389 where the compiler cannot easily be sure that an object has been unmodified).

@bradzacher bradzacher marked this as a duplicate of #16389 Jul 14, 2017
@KiaraGrouwstra
Copy link
Contributor

Adding expression level syntax is anti pattern for TypeScript.

Like assertion operator !? :P

@KiaraGrouwstra
Copy link
Contributor

@bradzacher: interestingly the checker actually has some isSideEffectFree check.

@michaeljota
Copy link

What's the difference between immutable and the generic type helper Readonly, and its many siblings?

@bradzacher bradzacher marked this as not a duplicate of #16389 Apr 18, 2018
@bradzacher
Copy link
Contributor Author

bradzacher commented Apr 18, 2018

Readonly<T> can only be applied to an interface.
readonly applies to properties on an interface.

As described in the proposal, the idea would be that immutable works like so:
when a variable is declared with immutable, it acts like const.
when a property is declared with immutable, it acts like readonly.
Except in both cases it also:

  • essentially automatically, recursively applies Readonly<T> to objects.
  • only allows the variable to be passed to pure functions.
  • only allows pure methods to be called on the variable (i.e. cannot call push on an immutable array).

The two keywords pure and immutable are intended to be used together to enable compile-time validated, side-effect free code.

@RyanCavanaugh
Copy link
Member

The concepts of immutability and readonly shouldn't be confused - if you have a reference to an immutable array, you can be sure its contents won't change, but a reference to a readonly array may be an alias for an object which someone else has a non-readonly reference to (thus its contents can observably change).

@michaeljota
Copy link

I understand that the compiler can't enforce immutability so far, but with Readonly it seems like immutable is just an alias for
const a: Readonly<T>

One thing that I see with this, is that you can't use Readonly with primitives, but I think that's in a PR or something, so soon we will have that option too.

Still, I know this is not real immutability, but I think this is somewhat better, as it allows you to work as you wish.

@bradzacher
Copy link
Contributor Author

Readonly<T> only applies to one level.

i.e.

interface One {
	prop : Two
}

interface Two {
	otherProp : number
}

const x : Readonly<One> = {
    prop: {
        otherProp: 1,
    },
}

// compiler error
x.prop = { otherProp: 2 }

// compiles fine!
x.prop.otherProp = 2

the idea is that immutable applies recursively to a variable, its properties, and their properties, etc.
In that case, immutable would could be represented by something like.

type DeepReadonly<T> = {
    readonly [K in keyof T]: DeepReadonly<T[K]> 
}
const y : DeepReadonly<One> = {
    prop: {
        otherProp: 1,
    },
}

// compiler error
y.prop = { otherProp: 2 }

// compiler error
y.prop.otherProp = 2

Note however that DeepReadonly<T> doesn't work as it breaks things like functions.. This can probably be solved with some conditional type wizardry from 2.8.x, but I need to read the spec for that to learn more...

If something like DeepReadonly<T> makes it into the typescript base defs so it's globally available, then that's one use case of immutable taken care of.

What you don't get is that immutable would prevent impure methods from being called on the object.

For example:

interface One {
    prop : Two
    mutate : () => void
}

interface Two {
	otherProp : number
}

type DeepReadonly<T> = {
    readonly [K in keyof T]: T[K] extends object
        ? (
            T[K] extends () => void
            ? T[K]
            : DeepReadonly<T[K]>
        )
        : T[K]
}
const y : DeepReadonly<One> = {
    prop: {
        otherProp: 1,
    },
    mutate() {
        this.prop.otherProp = 2
    },
}

// compiler error
y.prop = { otherProp: 2 }

// compiler error
y.prop.otherProp = 2

// works fine
y.mutate()

console.log(y.prop.otherProp) // === 2

A good example of this in practice is arrays. Array.prototype.push is an impure method - it mutates the underlying array. With the immutable keyword, this would be blocked:

immutable arr = [1]

// compiler error - calling impure method on immutable variable
arr.push(2)

There is the ReadonlyArray<T> interface as part of typescript standard, which is just the Array<T> type, without the impure functions!
However going through each and every standard API, and creating a separate readonly interface definition for people to use if they choose is cumbersome for the typescript maintainers, and for typescript consumers.
Additionally you would have to rely on package developers to do the same thing for each and every one of their objects (if they don't code pure)...

@michaeljota
Copy link

michaeljota commented Apr 19, 2018

I was going just mention that, with conditional typing, you could create a DeepReadonly<T> interface.
I'm thinking about something like this

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? (T[K] extends () => void
        ? T[K]
        : (T[K] extends Array<any> ? ReadonlyArray<T[K]> : DeepReadonly<T[K]>))
    : Readonly<T[K]>
};

Is this what you would see with the immutable keyword?


However going through each and every standard API, and creating a separate readonly interface definition for people to use if they choose is cumbersome for the typescript maintainers, and for typescript consumers.
Additionally you would have to rely on package developers to do the same thing for each and every one of their objects (if they don't code pure)...

You would likely do the same with this keyword, so I don't see any differences.

@bradzacher
Copy link
Contributor Author

You could certainly achieve something close to immutable with the conditional typing. It would be an ugly definition to cover all of the cases, but it'd give you the recursive readonly that you want.

However, you do not gain the ability to ensure no side-effects from methods and functions. Which means the case before with an impure method on the object can still happen (a la [].push).
Also Readonly<T> types are assignable to T, which means this is valid code:

interface One {
    prop : number
}

const x: Readonly<One> = {
    prop: 1,
}

function impure(arg: One) {
    arg.prop = 2
}

// compiles fine
impure(x)

You would likely do the same with this keyword, so I don't see any differences.

You would, but it would be easier for authors to do so.

Rather than having to create a separate Readonly version of each of their types (like ReadonlyArray), they can just annotate their existing types with the pure keyword (see the array example in the original post).
This is easy for contributors to PR and involves less duplication.

It also means that consuming a library in a pure way is the same as consuming it in an impure way, which makes code easier to understand and onboard on.

@michaeljota
Copy link

michaeljota commented Apr 20, 2018

Also Readonly types are assignable to T, which means this is valid code

Maybe this is something that is worth looking, because T is don't assignable to Readonly (or at least, its ReadonlyArray sibling isn't to Array).

An option to check not only the types, but the modifiers of an object, would be a great proposal.


I know that static checking about immutability is a good thing to develop, but this is only for you, and your team, in your project. If you are developing a library, you should not count on the immutability of the compiler, because this is actually JS at the end, and anyone who does not use your library with Typescript, but plain old JS will be capable of mutating your data, there is nothing you can do about it, but use a library to avoid the mutation all together, like Immutable or something.

I want to use Typescript to handle mutation, but I know that at the end, that would work just for me, and I am actually ok with that. That's a feature, not a bug.

@RyanCavanaugh RyanCavanaugh added the In Discussion Not yet reached consensus label Aug 15, 2018
@michaeljota
Copy link

@bradzacher Reading this again, I notice that you can have a real Immutable type helper:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? (T[K] extends () => void
        ? T[K]
        : (T[K] extends Array<any> ? ReadonlyArray<DeepReadonly<T[K][0]>> : DeepReadonly<T[K]>))
    : Readonly<T[K]>
};

It's a little different to the one I first proposed, and I'm actually assuming that all the objects in the Array are the same shape if they are a tuple, but it works as expected. So, that would cover the immutable keyword.

As the pure keyword, I think the best way to achieve that would be to allow readonly keyword in function arguments, and combining it with DeepReadonly as well. This won't give any errors if you are calling the function as is a side-effect function, TSLint have a rule for that (no-unused-expression), but you wouldn't be able to modify any part of the arguments, in theory that's what makes a pure function pure.

What do you think about that? I really think that even if DeepReadonly can be provided in userland, it should be included in TS [#14909], and readonly in function arguments also have an issue. (But I can't find it).

@munizart
Copy link

@michaeljota

... but you wouldn't be able to modify any part of the arguments, in theory that's what makes a pure function pure.

Well consider this:

function detonator (readonly destructionCodes: DeepReadonly<DestructionCode[]>) {
  if (checkCodes(destructionCodes) {
    nuke()
  }
}

This functions serious indicators that it performs side-effets (eg. returns nothing, may start a nuclear war, etc). I is certain not a pure function, even tho it doesn't modify arguments.

In addition to @bradzacher's proposal, IMHO, pure functions should explicit return on every paths, returning undefined migth be ok.
High-order pure functions should be able to require pure functions as arguments, but may return unpure ones.

Also, @bradzacher can pure functions instantiate new objects?

@emilioplatzer
Copy link

I have write a similiar issue here: #42758

I like this issue but I do not mix const with immutable.

I suggest to improve this proposal with:

  • adding readonlyReference concept (with the name that you want: immutable, immutable_ref, ro_ref, etc...)
  • saying that pure functions means readonlyReference of this
  • do not mix const with readonlyReference
class Figure{
     public name:string, 
     pure showName(){
         console.log(this.name);
     }
     sufixName(sufix:string){
         this.name += sufix
     }
}

var figures: Figure[];

var figureRef: readonlyReference Figure;

for(figureRef or figures){ // I need figureRef not to be const but check for not modify referenced object. 
    figureRef.showName(); // ok because showName is pure
    figureRef.sufixName('!');  // bad bacause sufixName
}

@jpierson
Copy link

Just to encourage some cross conversation with a similar proposal for C# I want to draw attention to the now closed proposal to add a way to denote that a function/method is pure in C# but has been closed since 2017. Making progress in Typescript on alternative ways to improve the safety of applications without having to go full blown Haskell would be quite awesome if it's possible.

dotnet/roslyn#7561

@jpierson
Copy link

Additionally as I've noted in the other discussion Rust Lang appears to have adopted a const modifier for functions which allows for some of the behavior that I think is being asked for here. With the Deno project which seems to have had a high amount of collaboration between Typescript and Rust development Rust and it's precedence may be a good source of inspiration.

https://blog.rust-lang.org/2018/12/06/Rust-1.31-and-rust-2018.html#const-fn

@bradzacher
Copy link
Contributor Author

An example of an implementation of a "purity" system is the contexts and capabilities system built into Hack:
https://docs.hhvm.com/hack/contexts-and-capabilities/available-contexts-and-capabilities
This system is more flexible than just my "disallow impure actions" proposal.

In the hack system you can opt a function into this system by adding [] before the return annotation

- function foo(): void {
+ function foo()[]: void {
    // ...
  }

This declares the function as having no capabilities - it has to be completely pure.
Within the brackets you can include the name of a "context". Contexts declare what "capabilities" a function can have (for example - can it write properties, or can it do IO operations).

A function with capabilities can only call other functions with the same capabilities.
This system is leveraged heavily at Facebook to ensure that codegen pipelines are consistent and stable.

@rubenlg
Copy link

rubenlg commented Apr 12, 2022

FWIW, I'd be very interested in pure functions along with a compiler check to make sure you do not have unused side-effect free expressions. In some cases I have had to deal with errors where I forgot to return the value of a pure function (I forgot the return statement) and it would be great if the compiler would remind me that this is dead code in practice so I would notice the error right away.
Right now there is no way to annotate that a function is pure and just calling it without using the return value is a bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

10 participants