microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.05k stars 12.37k forks source link

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

Open bradzacher opened 7 years ago

bradzacher commented 7 years ago

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:

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:

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


### With objects/interfaces
The keyword(s) should also be allowed in `object` (and by extension `interface`) definitions:
```TS
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;
}
kitsonk commented 7 years ago

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.

bradzacher commented 7 years ago

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).

KiaraGrouwstra commented 7 years ago

Adding expression level syntax is anti pattern for TypeScript.

Like assertion operator !? :P

KiaraGrouwstra commented 7 years ago

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

michaeljota commented 6 years ago

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

bradzacher commented 6 years ago

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:

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

RyanCavanaugh commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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.

michaeljota commented 5 years ago

@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 commented 4 years ago

@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 commented 3 years ago

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:

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 commented 3 years ago

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.

https://github.com/dotnet/roslyn/issues/7561

jpierson commented 3 years ago

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 commented 2 years ago

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 commented 2 years ago

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.