microsoft/TypeScript

Mark/infer a function or property as pure/stateless

dead-claudia opened this issue ยท 14 comments

I think a way to mark and/or infer a function as having no state would be extremely beneficial for code optimization. If a function incurs no state, there are a variety of optimizations that can be applied to it that otherwise couldn't be done. Also, getters and functions that don't modify state can be removed if their results aren't used. Class methods could do the same thing.

Another area where this could be helpful is that if it's explicitly marked, it would be compile-time checked to be fully immutable, i.e. it cannot indirectly modify state. The checks to infer/assert can be independent of let/const, which would make things easier.

An explicit marker would also be very useful for type definition files, given some libraries like Lodash and React mostly consist of mutable methods.

#12 covers readonly-ness which would be required for this. Inferring purity is unlikely to ever happen, if it were easily feasible you'd have seen it added to many other static languages by now. As it stands the TypeScript compiler is not really in the business of type directed optimizations/emit so it's unclear how much value this would really add as far as performance and optimizations. Obviously there is some value in the annotations as documentation and contracts but they'd need to be enforced to be valuable.

Although a keyword modifier for enforcing purity in a whole function may work. Maybe something like this?

// Function with side effects
function doSomething(x: number, y: number): void;

imm function add1(x: number, y: number) {
  doSomething(x, y); // Error
  return x + y;
}

// No error.
imm function add2(x: number, y: number) {
  return x + y;
}

// Entire class is designated immutable
imm class Monad<T> {
  constructor(public value: T);
  fmap<U>(f: (value: T | Monad<T>) => U): U;
}

imm function add3(x: Monad<number>, y: Monad<number>) {
  return x.fmap(imm x => y.fmap(imm y => x + y));
}

Maybe also, potentially allowing a block to be declared immutable, which can simplify such code. The block would be evaluated as if it didn't exist (no new block scope introduced), but everything in it would be considered immutable (no setting external variables/properties, no calling stateful methods, see notes).

function calculateAndPrint(bar: number) {
  console.log(doCalculation(bar));
}

imm {
function complexCalculation(x: number, y: number) {
  return x + y;
}

function doCalculation(x: number) {
  return complexCalculation(x, Math.sqrt(x));
}
}

Another thing is that function types should also be able to be required to be immutable, and immutable types considered distinct from mutable ones:

type ImmArrayLike<T> = {imm [key: number]: T};

interface Array<T> {
  imm map<U>(this: ImmArrayLike<T>, f: imm (value: T, index: number, array: T[]) => U): U[];
  map<U>(f: (value: T, index: number, array: T[]) => U): U[];

  imm filter(this: ImmArrayLike<T>, f: imm (value: T, index: number, array: T[]) => any): T[];
  filter(f: (value: T, index: number, array: T[]) => any): T[];

  imm reduce<U>(this: ImmArrayLike<T>, f: imm (acc: U, value: T, index: number, array: T[]) => U): U;
  reduce<U>(f: (acc: U, value: T, index: number, array: T[]) => U): U;

  imm join(this: {imm [key: number]: T, imm toString(): string}, sep: string): string;
  join(sep: string): string;

  forEach(f: (value: T, index: number, array: T[]) => any): void;

  imm every(this: ImmArrayLike<T>, f: imm (value: T, index: number, array: T[]) => any): boolean;
  every(f: (value: T, index: number, array: T[]) => any): boolean;

  imm some(this: ImmArrayLike<T>, f: imm (value: T, index: number, array: T[]) => any): boolean;
  some(f: (value: T, index: number, array: T[]) => any): boolean;

  imm indexOf(this: ImmArrayLike<T>, entry: imm T, start?: number): number;
  indexOf(entry: T, start?: number): number;

  // ES7
  imm includes(this: ImmArrayLike<T>, entry: imm T): number;
  includes(entry: T): number;
}

Note 1: immutability does not guarantee 0 state within a function. It just means the state is limited to only setting variables local to the function. These should count as immutable:

imm function map<T, U>(list: T[], f: imm (value: T) => U): U[] {
  const list: U[] = new Array(list.length); // Array constructor is stateless
  for (let i = 0; i < list.length; i++) list[i] = f(list[i]);
  return list;
}

imm function foo() {
  let bar = 1;
  function inner() {
    bar = 2;
  }
  inner();
  return bar;
}

Note 2: implemented class instance properties are implicitly immutable if they are a primitive (i.e. booleans, numbers, strings, symbols, etc.), but getters, interface members, and ambient declarations must be explicitly so.

I'm definitely open to criticism here, and I have no real attachment to imm as the keyword here, other than it's short.

imm is confusing at best. What would be wrong with static? Also, what would some of the emits look like?

@kitsonk

  1. That could work as well. I also mentally toyed with const, which IMO would fit with the theme of const variables and const enums. The specific keyword I don't really care about.
  2. The emits wouldn't change, other than calls to immutable functions would not be emitted unless they are assigned to something or passed as an argument to something, with (preferably) a compiler warning for each call. Minifiers can take advantage of removing unused functions.

A pure function is not the same as a static function. Therefore, the static keyword shouldn't be used to denote that a function is pure. A better (and new, of course) keyword would be pure, in my opinion.

@mariusschulz I agree, which is why I initially thought of imm and const as possible candidates. And I think I also considered pure for a couple seconds as well. Let's just say I'm not the best one at bikeshedding the keyword in question here... ;)

I'm skeptical of how well this could work out in practice. A common problem with purity/constness enforcement is that it's "infectious" - once you start trying to make part of your codebase respect this flag, you have to convert everything, which will soon run in to third-party libraries that do wacky stuff (e.g. Knockout, where one overload of a function is pure and one isn't).

@jbondc there is a reason almost no mainstream languages infer purity, especially those that aren't of the functional heritage with immutability as a default. It is immensely complicated and expensive, and not even desirable for many people. Surely Java, Scala, C#, F# and many others would benefit from this feature as much or more than TypeScript, and some of those languages are far more biased to a pure functional world already. Yet none of them have purity inference (or even explicit purity annotations) despite 10+ years more feature development time than TypeScript has had. There's no shortage of serious academic work in this area and none of it has percolated up into a functioning system in a popular language.

@danquirk Nit: F# is immutable by default. And Scala doesn't have an explicit default (var vs val), but immutability (val) is preferred.

@jbondc Nit: C# does have some limited type inference, but it's limited to local variable types. C++ looks to be getting more and more type inference capabilities, including inferred auto lambda argument and return types in C++14, and correctly inferred direct list-initialization (x would be inferred to be of y's type in auto x{y};) in the C++17/C++1z draft.

@IMPinball F#'s let bindings are immutable by default but that does not do anything in terms of purity as phrased here. There're no pure functions as far the compiler is concerned, checking/validation around that fact, or optimizations related to purity.

Is this included in/superseded by #7770? Because this issue was last commented on over three years ago while #7770 has recent activity, so I'm just curious if maybe that's because the discussion has moved there and this one here is obsolete?

BTW, I'd like to see this inferred for implementation functions, explicit in type declarations. readonly could swap the default - getters really shouldn't be mutating anything apart from lazy accesses.

Whatever route is chosen, I'd like the ability to override inferred impurity going "this is pure even though it doesn't look like it", in cases like cached computations.

Tracking at the more popular #7770