microsoft / TypeScript

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

Meta-issue: Use Full Unification for Generic Inference? #30134

Open RyanCavanaugh opened 5 years ago

RyanCavanaugh commented 5 years ago

Search Terms

unification generic inference

Suggestion

Today, TypeScript cannot retain or synthesize free type parameters during generic inference. This means code like this doesn't typecheck, when it should:

function identity<T>(arg: T) { return arg; }
function memoize<F extends (...args: unknown[]) => unknown>(fn: F): F { return fn; }
// memid: unknown => unknown
// desired: memid<T>(T) => T
const memid = memoize(identity);

Use Cases

Many functional programming patterns would greatly benefit from this.

The purpose of this issue is to gather use cases and examine the cost/benefit of using a different inference algorithm.

Examples

Haven't extensively researched these to validate that they require unification, but it's a start:

9366

3423

25092

26951 (design meeting notes with good examples)

25826

10247

essenmitsosse commented 5 years ago

Wouldn't that be a solution:

function identity<T>(arg: T) { return arg; }
function memoize<F extends <G>(...args: G[]) => G,G>(fn: F): F { return fn; }

// memid<T>(T) => T
const memid = memoize(identity);

Which wouldn't work with non generic functions, but this could be fixed like that:

function memoize<F extends ( <G>(...args: G[]) => G ) | ( (...args: G[]) => G ),G>(fn: F): F { return fn; }

// memid<T>(T) => T
const memid1 = memoize(identity);

function stupid(arg: string) { return arg; }
// memid (string) => string
const memid2 = memoize(stupid);

It's definitely not pretty, but it seems to do the job.

RyanCavanaugh commented 5 years ago

Correct. memoize is possible to write a workaround for, but in more complex cases it isn't possible to fix in user code.

dragomirtitian commented 5 years ago

@essenmitsosse

This approach would work only for really simple cases. If for example there is any constraint on the type parameter of identity we will get an error, and there really is no way to forward the generic type constraint. Also if the number of type parameters is variable we again have an issue, but this could be solved with a number of overloads.

@RyanCavanaugh Not sure if this is in scope here, but I have seen people often struggle with generic react components and HOCs. There really is no good way to write a HOC that forwards generic type parameters, maintains generic type constraints and removes some keys from props. Not sure if this will ever be possible but one can dream :). A simple example of what I am talking about:

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

function HOC<P extends { hocProp: string }>(component: (p: P) => void): (p: Omit<P, 'hocProp'>) => void  {
  return null!;
}

// Works great for regulart components
// const Component: (props: { prop: string }) => void
const Component = HOC(function (props: {  prop: string, hocProp: string}) {});

// Curenly an error, ideally we could write HOC to get 
// const GenericComponent: <T extends number | string>(p: {  prop: T }) => void
const GenericComponent = HOC(function <T extends number | string>(p: {  prop: T, hocProp: string}) {

})
yortus commented 5 years ago

@RyanCavanaugh is #29791 another example of something that could be addressed by this? You mention the absence full unification in your comment there.

airlaser commented 5 years ago

I'm currently running into this issue, or at least what I think is this issue. I have a class that extends an interface that uses generics. I have to explicitly type the parameters on methods in my class even though the type is already explicitly stated on the interface. If I don't, it complains that I didn't type it and calls it an any. If I type it incorrectly, it tells me the type is wrong, so clearly it knows what type it's supposed to be.

An example:

interface FolderInterface<FolderInfo> {
      getFolderName: (folder: FolderInfo) => Promise<string>;
}

class FolderImplementation implements FolderInterface<WindowsFolder> {
    getFolderName = async (folder: WindowsFolder) => apiCallForFolderName(folder);
}

In FolderImplementation if I don't explicitly type folder it complains that Parameter 'folder' implicitly has an 'any' type. If I purposefully type it incorrectly (for example, as string) it complains that Type '(folder: string) => Promise<string>' is not assignable to type '(folder: WindowsFolder) => Promise<string>'.

Would this be fixed by this? It would be a lot less verbose if I didn't have to re-explicitly-type everything.

johnsonwj commented 5 years ago

I think I also have an example for this issue:

class Pair<L, R> {
    constructor(readonly left: L, readonly right: R) {}
}

class PairBuilder<L, R> {
    constructor(readonly r: R) {}

    build(l: L) { return new Pair(l, this.r); }

    map<R2>(f: ((r1: R) => R2)): PairBuilder<L, R2> { return new PairBuilder(f(this.r)); }
}

const makePairBuilder = <L> (r: string) => new PairBuilder<L, string>(r);

function f0(): PairBuilder<string, string> {
    return makePairBuilder('hello');
    // resolves as expected
    //    const makePairBuilder: <string>(r: string) => PairBuilder<string, string>
}

const badPair = f0().build({});
// type error as expected
//     Argument of type '{}' is not assignable to parameter of type 'string'.

const pair = f0().build('world');
// resolves as expected
//    const pair: Pair<string, string>

function f1(): PairBuilder<string, string> {
    return makePairBuilder('hello').map(s => s + ' world');
}

// unexpected type error:
//    Type 'PairBuilder<unknown, string>' is not assignable to type 'PairBuilder<string, string>'.

in f0(), the compiler correctly narrows the type for makePairBuilder to match the declared return value. However, when that call is "hidden" behind an extra call to map as in f1(), the inference fails, even with an explicit declaration.

This case may be a more specific and easier to analyze issue; the left type is unchanged by PairBuilder.map which makes it easier to conclude that we can carry it forward through the map() call.

(edit1: fix some wording)

(edit2: remove the unnecessary Pair.map function)

bogdanq commented 4 years ago

link

import React from 'react'

function Parent<T>({ arg }: { arg: T }) { 
    return <div />
}

interface Props<T> {
    arg: T;
}

const Enhance: React.FC<Props<number>> = React.memo(Parent)

const foo = () => <Enhance arg={123} />
ghost commented 4 years ago

Just tried out above example, with TS 3.7.2 it seems to be working now?

function identity<T>(arg: T) { return arg; }
function memoize<F extends (...args: unknown[]) => unknown>(fn: F): F { return fn; }

const memid = memoize(identity); // memid: <T>(arg: T) => T

const fn = memid((n: number) => n.toString()) // fn: (n: number) => string

Playground

In contrast to 3.6.3 Playground

I am bit puzzled (in positive sense), @RyanCavanaugh did I miss out a feature or so?

RyanCavanaugh commented 4 years ago

@ford04 good point - the issue in the OP was tactically fixed by allowing some transpositions of type parameters. I need to write up a new example

ekilah commented 4 years ago

Is this SO post relevant for your need for a new example @RyanCavanaugh ? https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref

Specifically, I got here because I'm trying to correctly type a call to React.forwardRef, where the component I'm forwarding a ref to has a generic props type. Is this possible? I've struggled to figure out a way to do this without losing the type safety of the generic props type.

csvn commented 4 years ago

@RyanCavanaugh I'm not 100% sure, but I think this issue describes what I'm trying to do with the below example:

declare const values: number[];
declare function passFirst<T extends (...args: any[]) => void>(fn: T): (...args: Parameters<T>) => Parameters<T>[0];
const map = values.reduce(
  passFirst((res, v) => res.set(v, v * 2)),
  new Map<number, number>()
);
// map, res and v are all type `any`

Playground example Simple playground example

I found in a project that I was often converting an array of values to a Map, and wanted a utility function that called the provided function with it's arguments, but returned the first parameter. Unfortunately I could not get it to work, as the generic typing always became too general (even if I tried multiple generic parameters).

I was thinking about creating a new issue for this, until I finnally found this one 😀

awerlogus commented 4 years ago

@RyanCavanaugh, I have some ideas. At first you should make function signature checker work with more complicated cases. For example, if we'll try to use wrapping type checkers, it will not work as expected.

To show it we need to define some infrastructure for advanced function check:

type Tuple = Array<any> & { 0: any } | []

type FixedTuple<T extends Tuple> = T & { length: T['length'] }

type FixedParams<F extends (...args: unknown[]) => unknown> =
  F extends (...args: infer P) => unknown ? P extends Tuple ? FixedTuple<P> : never : never

type StrictFunction<F extends (...args: unknown[]) => unknown> = F & ((...args: FixedParams<F>) => unknown)

// Type to show matching errors
type Matches<F1 extends StrictFunction<F2>, F2 extends (...args: unknown[]) => unknown> = [F1, F2]

And the example itself:

type Literal<T extends string> = string extends T ? never : T

type F = <T extends string>(str: Literal<T>) => T

type Valid = (str: 'https://xroom.app') => 'https://xroom.app'

type Invalid = (str: string) => string

// No errors, works as expected
type MatchesValid = Matches<F, Valid>

// Still no errors, but there should be, because
// type 'string' is not assignable to type 'never'
type MatchesInvalid = Matches<F, Invalid>

// Don't know why, but it works right, after adding an extra param.
type OneMoreParam = (str1: string, str2: string) => string

// Type 'A' does not satisfy the constraint 'StrictFunction<OneMoreParam>'.
// Type 'A' is not assignable to type '(...args: FixedTuple<[string, string]>) => unknown'.
// Types of parameters 'str' and 'args' are incompatible.
// Type 'FixedTuple<[string, string]>' is not assignable to type '[never]'.
// Types of property '0' are incompatible.
// Type 'string' is not assignable to type 'never'.ts(2344)
type MagicCheck = Matches<F, OneMoreParam>

I hope, it will be helpful for you. Other ideas will be published later.

millsp commented 3 years ago

Here's another example, extracted from a discussion:

Play it

import { Vector } from "react-native-redash";

type Tuple<
  T extends unknown,
  N extends number,
  S extends T[] = []
> = S["length"] extends N ? S : Tuple<T, N, [T, ...S]>;

type Curve = {
  c1: Vector;
  c2: Vector;
  from: Vector;
  to: Vector;
};

type Path<N extends number | void = void> = {
  curves: N extends number ? Tuple<Curve, N> : Curve[];
};

const interpolatePath = <
  I extends [number, number, ...number[]],
  N extends number
>(
  v: number,
  inputRange: I,
  outputRange: Tuple<Path<N>, I["length"]>
) => {
  console.log({ v, inputRange, outputRange });
};

const curve = {
  c1: { x: 0, y: 0 },
  c2: { x: 0, y: 0 },
  from: { x: 0, y: 0 },
  to: { x: 0, y: 0 }
};

const p1: Path<1> = {
  curves: [curve]
};
const p2: Path<1> = {
  curves: [curve]
};

interpolatePath(1, [1, 2], [p1, p2]);

Here we would like N of interpolatePath to be inferred the size of the tuple items.

Farenheith commented 3 years ago

Is this an example for this issue?

function test<T>(f: (a: number[]) => T) {
  return f([1, 2, 3]);
}

function callbackProvider<T extends R[], R>(): (a: T) => R {
  return (a: T) => a[0];
}

const result = test(callbackProvider()); // T is number[], but R is unknown, so result type is unknown

Playground Link

I could get it to work though, using conditional Type with a circular reference:

type ItemType<T> = T extends Iterable<infer R> ? R : never;

function test<T>(f: (a: number[]) => T) {
  return f([1, 2, 3]);
}

function callbackProvider<T extends ItemType<T>[]>(): (a: T) => ItemType<T> {
  return (a: T) => a[0];
}

const result = test(callbackProvider()); // T is number[] and result type is number

Playground Link

mlhaufe commented 3 years ago

Is this still an issue? the original code seems to work in the current version:

Playground Link

function identity<T>(arg: T) { return arg; }
function memoize<F extends (...args: unknown[]) => unknown>(fn: F): F { return fn; }
// const memid: <T>(arg: T) => T
const memid = memoize(identity);
hayes commented 3 years ago

I've read through a bunch of the related issues here, but I don't have enough background in type systems to fully understand what all is implied by full unification. Based on comments on some other threads I gather that it implies an unbounded number of passes for inference, rather than a set number of passes that exist in the current system.

Would it be possible to solve some of the related issues by adding a step that infers positions that either do not depend on a generic, or depend only on generics who's types of already been resolved.

A simple example would be something like

function example<T>(options: {
  a: (arg: unknown) => T;
  b: (arg: T) => number;
}) {};

example({
  a: (arg) => ({ id: 123 }), // <-- return can't currently be inferred because we need to infer arg first
  b: (arg) => arg.id, // <-- arg is undefined
});

Playground link

In the above example, arg in function a does not depend on any of the unresolved generics, if it were inferred first, everything else could be inferred correctly. I really don't have much of an understanding of how TS works under the hood, so this probably doesn't make sense, but wanted to check if there was a simpler solution for a subset of the problem space that might be worth exploring.

Eliav2 commented 3 years ago

Another example that happens all the time to those of us who use noImplicitAny:false:

interface Test<S> {
  func1: (arg) => S;
  func2: (arg:S) => any;
}

function createTest<S>(arg: Test<S>): Test<S> {
  return arg;
}

createTest({
  func1: () => {
    return { name: "eliav" };
  },
  func2: (arg) => {
    console.log(arg.name); //works - name is recognized
  },
});
createTest({
  func1: (arg) => {
    return { name: "eliav" };
  },
  func2: (arg) => {
    arg; // type unknown, why?
    console.log(arg.name); //ERROR - name is NOT recognized
  },
});

very annoying

nhhockeyplayer commented 2 years ago

My HashSet wont work anymore

TypeError: value.hash is not a function

IUserDTO-> hash() UserEntity implements IUserDTO-> hash(){}

    add(value: T): void {
        const key: string = value.hash()
        if (!this.hashTable[key]) {   
            this.hashTable[key] = value
        } else {
            throw new RangeError('Key ' + key + ' already exists.')
        }
    }

typescript says value.hash() is not a function when I modeled it properly with an interface and a class that implements the interface in fact it had been working great as hashable

export interface IHashable {
    hash?(): string
}

export interface IHashTable<T> {
    [hash: string]: T
}

but now my generic wont work anymore

whats going on?

Only diff is Im pulling entities off the back end http and collecting them as typeorm entities

are generics toast now? This would take out the entire abstraction layer across the industry.

nhhockeyplayer commented 2 years ago

I use "noImplicitAny": false, to accomodate previously stealth working deep-spread constructs

nhhockeyplayer commented 2 years ago

well my issue might be this

declaring IHashable interface to be a class Hashable instead of interface

maybe this is why Im not getting anything under the hood

export class Hashable {
    hash?(): string
}

export interface IHashTable<T> {
    [hash: string]: T
}

export class HashSet<T extends Hashable> implements Iterable<string>, Iterator<T> {
    protected position = 0
    private hashTable: IHashTable<T>

strange how angular and typescript will let one get away with and run with until it finally shows up

I cant imagine generics not working

nhhockeyplayer commented 2 years ago

still fails

TypeError: value.hash is not a function
    at eval (eval at add (http://localhost:4200/main.js:1:1), <anonymous>:1:7)
    at HashSet.add (http://localhost:4200/main.js:12607:27)
    at HashSet.populate (http://localhost:4200/main.js:12573:18)

Im peeling a TypeORM entity off the back end successfully in its own class that implements hash()

can anyone answer if generics are broken?

juanrgm commented 2 years ago

Another example:

function test<TItem, TValue>(data: {
  item: TItem;
  getValue: (item: TItem) => TValue;
  onChange: (value: TValue) => void;
}) {}

test({
  item: { value: 1 },
  getValue: (item) => item.value,
  onChange: (value) => {}, // value is unknown
});

test({
  item: { value: 1 },
  getValue: (item) => 1,
  onChange: (value) => {}, // value is unknown
});

test({
  item: { value: 1 },
  getValue: () => 1,
  onChange: (value) => {}, // value is number
});
Andarist commented 2 years ago

Based on the comment here: https://github.com/microsoft/TypeScript/issues/44999#issuecomment-883531098 the "full unification" algorithm would solve the issue outlined in that issue.

echocrow commented 2 years ago

Adding to the list of examples:

Workbench Repro

Excerpt:

interface Example<A, B> {
  a: A
  aToB: (a: A) => B
  useB: (b: B) => void
}
const fn = <A, B>(arg: Example<A, B>) => arg

const example = fn({
  a: 0,
  aToB: (a) => `${a}`,
  useB: (b) => {}
})
// want: Example<number, string>
// got: Example<number, unknown>

Similar (/identical?) in structure to what was reported in #25092.

Have additional utility types or syntax—i.e. some way of helping the TS compiler determine where to infer a generic, and where to just enforce it—been considered?

In the example above, the TS compiler currently seems to want to infer B from the parameter of useB(). As the code author, one could (at least in this case) tell the TS compiler to explicitly infer B from the return type of aToB().

Some examples, extending the example above:

// Explicitly specify from where to infer generic `B`.
interface Example<A, B> {
  aToB: (a: A) => infer B
  // or
  aToB: (a: A) => assign B
  // or
  aToB: (a: A) => determine B
  // or
  aToB: (a: A) => Infer<B>
}

// Or explicitly specify from where to _not_ infer (i.e. just enforce) generic `B`.
interface Example<A, B> {
  useB: (b: derive B) => void
  // or
  useB: (b: Derived<B>) => void
  // or
  useB: (b: Weak<B>) => void
}

Understandably this would just be a duct tape solution to the bigger shortcoming; ideally TypeScript would be able to infer these generics correctly. Given that a revamp of the generic inference algorithm (full unification or some multi-pass attempt) may be too complex at this point, maybe something like this could serve as an "intermediary" solution? No idea if this would be trivial to implement, or equally too complex.

One minor added benefit of explicitly telling the compiler from where to infer a given generic might be that type collisions could then be reported in places where such errors may be more expected. In the example above, a mismatch would be detected and reported in the useB() parameter, as opposed to the aToB() return statement.

Obvious downsides to this approach include additional syntax/utility type, and extra onus on code authors to comply with and work around the compiler.


EDIT (2024-02-08): TypeScript 5.4 will introduce a NoInfer<T> type, which I believe addresses the issue in this particular post.

jcalz commented 2 years ago

Is this issue the most appropriate one for the inference failure in the following?

declare function g<T>(x: T): T;
declare function h<U>(f: (x: U) => string): void

h(g) // error, inference of U fails and falls back to unknown
//~ <-- Argument of type '<T>(x: T) => T' is not assignable to parameter of type '(x: unknown) => string'.

h<string>(g) // okay, U manually specified as string, T is inferred as string
h(g<string>) // okay TS4.7+, T is manually specified as string, U is inferred as string

Playground link

Or does there exist another more specific GitHub issue somewhere for this? I can't find one if there is.

lifeiscontent commented 2 years ago

@RyanCavanaugh is there a possibility of taking a look at this issue for the next version? I've been running into this issue a lot lately within the react ecosystem.

Example

is there currently a way to overcome this shortcoming without specifying the generic?

huangyingwen commented 1 year ago

I can't restrict the last parameter to be RequestParams, I don't know if it's related to this problem

type BaseFunc<
  P extends
    | [RequestParams]
    | [never, RequestParams]
    | [never, never, RequestParams]
    | [never, never, never, RequestParams]
    | [never, never, never, never, RequestParams],
  T = any,
  E = any,
> = (...args: P) => Promise<HttpResponse<T, E>>;

export function useFetch<
  TP extends
    | [RequestParams]
    | [never, RequestParams]
    | [never, never, RequestParams]
    | [never, never, never, RequestParams]
    | [never, never, never, never, RequestParams],
  TFunc extends BaseFunc<TP>,
>(fetchApi: TFunc, ...params: Parameters<TFunc>) {
  let controller: AbortController;

  const fetch = (...args: Parameters<TFunc> | []) => {
    if (controller) {
      controller.abort();
    }
    controller = new AbortController();

    args[fetchApi.length] ??= {};
    args[fetchApi.length].signal = controller.signal;

    return fetchApi(...args).then(res => {
      return res;
    });
  };

  fetch(...params);

  onUnmounted(() => {
    controller?.abort();
  });

  return { fetch };
}
tpict commented 9 months ago

@RyanCavanaugh is there a possibility of taking a look at this issue for the next version? I've been running into this issue a lot lately within the react ecosystem.

Example

is there currently a way to overcome this shortcoming without specifying the generic?

Thanks for this playground–I thought I was losing my mind seeing onClick being inferred correctly according to the hover UI, but the args being implicitly any according to the type checker

jcalz commented 2 weeks ago

Linking to @ahejlsberg's comment https://github.com/microsoft/TypeScript/issues/17520#issuecomment-318935065 which I always look for when coming here:

[...] TypeScript's type argument inference algorithm [...] differs from the unification based type inference implemented by some functional programming languages, but it has the distinct advantage of being able to make partial inferences in incomplete code which is hugely beneficial to statement completion in IDEs.