TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Allow inferring a type alias for derived return types

Open ehaynes99 opened this issue 3 years ago • 4 comments

Suggestion

🔍 Search Terms

function inferred return type alias

✅ Viability Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

It would be extremely convenient if a function were able to declare a type alias for its return type inline. A possible syntax for this could be:

const createCounter = (): infer Counter => {
  const result = {
    count: 0,
    increment: () => ++result.count,
  };
  return result;
};

This would create a type alias automatically, allowing usage like:

import { Counter, createCounter } from './counter';

📃 Motivating Example

Currently, a class declaration automatically emits a type for the resulting constructor function. However, if an object is created with a standard function, this is not possible. The inferred return type of functions can be aliased like:

// aliased type:
// type Counter = {
//   get: () => number;
//   increment: () => number;
// }
export type Counter = ReturnType<typeof createCounter>;

const createCounter = () => {
  const result = {
    count: 0,
    increment: () => ++result.count,
  };
  return result;
};

This is an powerful pattern, as it allows the implementation to be the source of reference for the type, rather than the other way around. However, while the aliased type is derived from the function, the function itself does not return the aliased type.

// type is:
// const counter: {
//   get: () => number;
//   increment: () => number;
// }
// NOT Counter
const counter = createCounter();

In order to use the derived type, there are 2 options, both of which are clumsy:

  • Put the onus on the caller to explicitly use the type:
import { Counter, createCounter } from './counter';

const counter: Counter = createCounter();
  • Create a superfluous higher order function:
export type Counter = ReturnType<typeof createCounterInternal>;

export const createCounter = (): Counter => createCounterInternal();

const createCounterInternal = () => {
  // counter impl
};

💻 Use Cases

Any function that returns a complex object could benefit from this.

ehaynes99 avatar Jun 07 '22 22:06 ehaynes99

Admittedly, I don't know how much complexity this is under the hood, but as a language feature, it would be particularly nice if it could be used to alias the internals of a generic type, promises being the most obvious case:

const createCounter = async (): Promise<infer Counter> => {
  const result = {
    count: 0,
    increment: () => ++result.count,
  };
  return result;
};

ehaynes99 avatar Jun 10 '22 01:06 ehaynes99

I've been looking at one JS project and investigating how hard it would be to migrate it to Typescript and it reminded me this feature request because I noticed that this feature would make it much easier to do the migration.

Basically there are tons of files that create some object in this pattern:

export function coreContext() {
    var context = {};
    context.version = "1.0.0",
        context.someMethod = function (arg) {
            return "something"
        }
    // ...
    // lots of assignment to context
    // ...
    return context;
}

and there are tons of different files that also creates objects in similar manner but they require objects returned by other functions:

//---- module1.js
export function doSomething(context){
    // ... some logic
}

//---- module2.js
export function doSomethingElse(context){
    // ... some logic
}

//---- module3.js
export function iThinkYouGetTheIdea(context){
    // ... some logic
}

Ignoring the "expando assignment" pattern (which BTW I wish was more widely supported in TS) having possibility to just add infer CoreContext to define type name:

export function coreContext() infer CoreContext {
    var context = {};
    context.version = "1.0.0",
    context.someMethod = function(arg) {
        return "something"
    }
    //...
    // lots of assignment to context

    return context;
}

and use it later:

import { CoreContext } from './coreContext';
export function doSomething(context: CoreContext) {
    // ... some logic
}

would be a huuuge time saver for this JS to TS migration.

EDIT: It would be also very useful if such construct could also be added to JSDoc comment as an intermediate step in migration, but also to get better IDE experience for current JS files:

// @filename: coreContext.js
/**
 * @returns {infer CoreContext}
 */
export function coreContext(){
    var context = {};
    //...
    return context;
}
// @filename: module2.js
/**
 * Copies a variable number of methods from source to target.
 * @param { import("./coreContext").CoreContext } context
 */
export function doSomethingElse(context){
    // ... some logic
}

mpawelski avatar Aug 06 '22 23:08 mpawelski

Not that I want to shoot my own feature in the foot here, but that's a big scope expansion because the syntax you're using there isn't something that TS can currently infer. It's important to note that any value's type is "set in stone" at the point where it's declared. It can't iteratively expand a type as values are added.

That's a feature, though, IMO. What I like about TS's type inference is that it naturally encourages declarative style over imperative. Creating an object and imperatively adding things to it requires you to put an overly loose type like Record or any on the empty object and then "fill it up" with compatible values.

For example, the most direct conversion of your example is something like:

// return type is `Record<string, any>`
export function createContext() {
  // type of `context` is locked in here
  const context: Record<string, any> = {};

  context.first = 'one';
  context.second = 2;
  context.third = new Date(3);

  return context;
}

The inferred return type of the function is always going to be "whatever type the thing it returns is".

Similarly, if you just declare it as:

const context: any = {}

The function's inferred return type is any.

If you convert it to being declarative, TS can infer the context type, however:

// type is:
// function(): {
//   first: string;
//   second: number;
//   third: Date;
// }
export function createContext() {
  return {
    first: 'one',
    second: 2,
    third: new Date(3),
  };
}

And if imperative creation is absolutely required (it's usually not), you can always create the fields as values and then return the whole declarative object:

export function createContext() {
  const first = 'one';
  const second = 2;
  const third = new Date(3);

  return {
    first,
    second,
    third,
  };
}

That conversion is purely syntactical, so wouldn't be difficult to do (albeit tedious without some clever use of find/replace with regexes). These latter 2 cases would be in line with this feature request, though, as throughout the application, you probably don't want to have to refer to it as:

function someFunction(context: { first: string; second: number; third: Date }) { // ...

So this would make it easy to declare either of the above as:

function createContext(): infer CoreContext {
  // ...
}

function someFunction(context: CoreContext) { //...

// or with destructuring:
function anotherFunction({ first, third }: CoreContext) { //...

ehaynes99 avatar Aug 07 '22 23:08 ehaynes99

Not that I want to shoot my own feature in the foot here, but that's a big scope expansion because the syntax you're using there isn't something that TS can currently infer.

Yep, sorry. TS can infer it now just in JS files. Doing it in TS files is totally different feature request. Let's not discuss it here.

I should have prepared examples without this syntax. I was just looking at some JS project and it stayed in my head 😅

mpawelski avatar Aug 08 '22 21:08 mpawelski

I want to discuss where this infer Type statement can be applied and what should be its semantic.

For me this infer Type feature should be very limited in what it does. I think the mental model should be:

  1. it replaces "infer Type" with "Type"
  2. it declares type alias with inferred type in the "logical" scope.

Here is couple of scenarios that came to my mind:

  1. Return o function This is the main use case described by OP. The most useful IMO.

    //module1.ts
    export function myFun() infer FunResult {
        return {
            foo: "",
            bar: 1
        }
    }
    export { FunResult }
    

    I think infer should only introduce alias in the scope (here the module's scope). If you want to export it then you need to export it manually (export { FunResult }). This code is basically equivalent to:

    export function myFun(): FunResult {
      return {
        foo: "",
        bar: 1,
      };
    }
    type FunResult = {
      foo: string;
      bar: number;
    };
    export { FunResult };
    
  2. Variable declaration Should we allow using infer Type in variable declaration? How useful would it be?

    function someFun() {
      return {
        foo: "",
        bar: 1,
      };
    }
    
    function otherFunction() {
      let t1: infer FunType = someFun();
      let t2: FunType = {
        foo: "f",
        bar: 1,
      };
    }
    

    This code is equivalent to this (currently working) code:

    function someFun() {
      return {
        foo: "",
        bar: 1,
      };
    }
    
    function otherFunction() {
      let t1: FunType = someFun();
      type FunType = {
        foo: string;
        bar: number;
      };
      let t2: FunType = {
        foo: "f",
        bar: 1,
      };
    }
    

    I'm not sure how useful this scenario is. But the semantics of this code is quite clear to me (we already can define type aliases in function bodies and it clear to me that this types are local to the function's body)

  3. "Partial" infer (like (infer T)[]) Just as in conditional types I would expect infer to be able to "pick" part of a type (like Type from Array<Type>)

    function someFun() {
      return [
        {
          foo: "a",
          bar: 1,
        },
        {
          foo: "b",
          bar: 2,
        },
      ];
    }
    
    function otherFunction() {
      let t1: (infer FunType)[] = someFun();
      let t2: FunType = {
        foo: "f",
        bar: 1,
      };
    }
    

    "otherFunction" body is is equivalent to this (currently working) code:

    function otherFunction() {
      let t1: FunType[] = someFun();
      type FunType = {
        foo: string;
        bar: number;
      };
      let t2: FunType = {
        foo: "f",
        bar: 1,
      };
    }
    

In general I think such behavior of infer Type is would be quite clear and comprehensible for programmers.

mpawelski avatar Aug 24 '22 19:08 mpawelski

I want to discuss where this infer Type statement can be applied and what should be its semantic.

The semantics of the infer keyword already exist with conditional types:

type Result<T> = T extends Promise<infer R> ? R : T

While there is no formal definition in the docs, one might be:

infer <Alias>:

Given an unnamed type that can be statically inferred, assign the reified type
to the supplied alias

The semantics would remain unchanged. This merely alters the language syntax to allow it in a different place.

For me this infer Type feature should be very limited in what it does.

It already is. This change would merely add one additional allowable location which would be function return types.

I think the mental model should be:

The mental model should be the same as a class. Stripping away the syntactic sugar of ES6 classes, a class is really just a constructor function. TypeScript provides additional syntactic sugar the make it define:

  • A value, which is the the constructor function
  • A type alias, which is the instance type returned by the constructor function

Declaring a class gives access to both. Classes are quite limited, however, and this would allow similar convenience to "creator" functions, which are far more flexible.

I think infer should only introduce alias in the scope (here the module's scope). If you want to export it then you need to export it manually (export { FunResult }). This code is basically equivalent to:

That would completely defeat the purpose. Callers would be unable to use the alias, only the anonymous inferred type, so exporting the function without exporting the type alias would be exactly the same as omitting the type.

When you declare a class, the value and the type always share a lexical scope. Imagine if they didn't, and instead required an explicit export type ClassName

export class ExampleClass {
  constructor(
    public first: string,
    public second: number,
  )
}

// we forgot to export this commented line
// export type ExampleClass
// some-other-file.ts
import { ExampleClass } from './example'

// type of `example` would be an anonymous `{ first: string; second: number }`
const example = new ExampleClass('one', 2)

// and you couldn't explicitly refer to it, as this would be an error:
// Cannot find name 'ExampleClass'.ts (2304)
const example: ExampleClass = new ExampleClass('one', 2)

  1. Variable declaration Should we allow using infer Type in variable declaration?

Definitely not. It's conceptually inverted. Inference on the "left hand" of an assignment statement is inferring the type of the variable, not describing the type of the expression's result. The "right hand" expression is evaluated first and always produces a result of a specific, unambiguous type. Even if it's anonymous, any, or unknown, it's still a value with a concrete type. If you happen to assign it to a variable, the variable's type is a reflection of that value's type, not a definition for it.

let t1: FunType[] = someFun();
type FunType = {
  foo: string;
  bar: number;
};

someFun "owns" the type here. Any variable assigned to its result should be that type. The alias produces confusion, not clarity. The only reason you would want an alias on the consumer side is if someFun is external code with a missing or incorrect type. Even then, you're better off wrapping the function in another that defines a better type:

type FunType = {
  foo: string;
  bar: number;
}

const typedFun = (): FunType[] => {
  return someFun()
}

Then assigning variables to that "more correct" type:

// inferred type of `t1` is `FunType[]`
const t1 = typedFun();

ehaynes99 avatar Aug 27 '22 02:08 ehaynes99