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

Suggestion: Noninferential type parameter usage #14829

Closed
RyanCavanaugh opened this issue Mar 23, 2017 · 43 comments · Fixed by #56794
Closed

Suggestion: Noninferential type parameter usage #14829

RyanCavanaugh opened this issue Mar 23, 2017 · 43 comments · Fixed by #56794
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@RyanCavanaugh
Copy link
Member

We keep getting bugs like this and I keep not finding the original so I'm making a new one so we can find it.

Keywords: ignore type parameter optional inference

Problem

Often with generics, there will be some locations where a type parameter should be inferrable from usage, and other places where the type parameter should only be used to enforce typechecking. This comes up in a variety of contexts

class Animal { move }
class Dog extends Animal { woof }

function doSomething<T>(value: T, getDefault: () => T) { }
// Wanted an error here - getDefault() ought to return same type as 'value'
doSomething(new Dog(), () => new Animal());
declare function assertEqual<T>(actual: T, expected: T): boolean;
const g = { x: 3, y: 2 };
assertEqual(g, { x: 3 }); // Forgot y, wanted error

Proposal Sketch

We should be able to mark type parameter consumption sites as being "not eligible for inference". For example, let's say we had a special global type that the compiler knew not to unwrap during inference:

type NoInfer<T> = T;

Then we can annotate usage sites

function doSomething<T>(value: T, getDefault: () => NoInfer<T>) { }
// Wanted an error here - getDefault() ought to return same type as 'value'
doSomething(new Dog(), () => new Animal());
declare function assertEqual<T>(actual: T, expected: NoInfer<T>): boolean;
const g = { x: 3, y: 2 };
assertEqual(g, { x: 3 }); // Error
@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Mar 23, 2017
@mhegazy
Copy link
Contributor

mhegazy commented Mar 24, 2017

Is using multiple type parameters not an option?

function doSomething<T, U extends T>(value: T, getDefault: () => U) ;

function assertEqual<T, U extends T>(actual: T, expected: U): boolean;

@johnfn
Copy link

johnfn commented Mar 24, 2017

@mhegazy, speaking from my experience, I never ever would have guessed that

function doSomething<T>(value: T, getDefault: () => T) ;

could be amended to do what I want by changing it to

function doSomething<T, U extends T>(value: T, getDefault: () => U) ;

Multiple type parameters are an option! It's just that right now they are a very surprising and unintuitive one.

@mhegazy
Copy link
Contributor

mhegazy commented Mar 24, 2017

I would not have guessed that NoInfer was the solution :)

@PyroVortex
Copy link

Unfortunately, using the U extends T option has inconsistent behavior for contextual typing, due to the subtly different semantics.

declare function invoke<T, U extends T, R>(func: (value: T) => R, value: T): R;

declare function test(value: { x: number; }): number;

invoke(test, { x: 1, y: 2 }); // Works
test({ x: 1, y: 2 }); // Compiler error

jkillian pushed a commit to palantir/tslint that referenced this issue Mar 31, 2017
Type of `options` should not be used to infer type parameter `T`.
Refs: microsoft/TypeScript#14829
@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed In Discussion Not yet reached consensus labels May 1, 2017
@RyanCavanaugh
Copy link
Member Author

Need to collect some use concrete cases before attempting to move forward with this

@ajafff
Copy link
Contributor

ajafff commented May 1, 2017

I also ran into the problem that U extends T allows excess properties as @PyroVortex mentions above.

I found a rather simple work around: {} & T
The example above would then be:

declare function invoke<T, R>(func: (value: T) => R, value: {} & T): R;

declare function test(value: { x: number; }): number;

invoke(test, { x: 1, y: 2 }); // Compiler error
test({ x: 1, y: 2 }); // Compiler error

@RyanCavanaugh
Copy link
Member Author

@ajafff 😲 !

I would expect { } & T to be indistinguishable from T - not 100% sure if this is a bug or not but I would not take it as designed behavior.

@ajafff
Copy link
Contributor

ajafff commented May 3, 2017

not 100% sure if this is a bug or not but I would not take it as designed behavior.

@RyanCavanaugh can I expect an official statement whether this is supported / intended behavior or not? I don't want to rely on a bug that - when fixed - will break the build.

There may already be some programmers relying on it. I found that pattern while reading some issues in this bug tracker.

@jwbay
Copy link
Contributor

jwbay commented Jun 12, 2017

Ran into this when writing some tests.

interface User {
    name: string;
    age: number;
}

type ApiCall<T> = (...args: any[]) => Promise<T>;

declare const fetchUser: ApiCall<User>;

declare function mock<T>(call: ApiCall<T>, result: T): void;

//no error, expected missing property (and property completions ideally)
mock(fetchUser, { name: '' });

Edit: making the undesired inference location an intersection per above fixes things up here, too! Results in the expected missing property error.

declare function mock<T>(call: ApiCall<T>, result: {} & T): void;

@RyanCavanaugh
Copy link
Member Author

Just an update - T & { } creates a "lower-priority" inference site for T by design. I would move this from the "definitely don't depend on this" column to the "it's probably going to work for the foreseeable future" column.

@sylvanaar
Copy link

sylvanaar commented Aug 8, 2017

Maybe give it a nice wrapper, so it is very similar to the proposal.

type InferLast<T> = T & {}

Example:

type InferLast<T> = T & {}

class Animal { move }
class Dog extends Animal { woof }

function doSomething<T>(value: T, getDefault: () => InferLast<T>) { }
doSomething(new Dog(), () => new Animal()); // error on 2nd parameter

@ajafff
Copy link
Contributor

ajafff commented Aug 14, 2017

T & {} only works when not using strictNullChecks.
I encountered a problem when T is undefined, because undefined is not assignable to undefined & {}.

I found that mapped types also defer inference, so i tried type InferLast<T> = Readonly<T>;
That does not work for types with index signatures like arrays.

My final solution is a mapped type intersected with the type itself:

export type NoInfer<T> = T & {[K in keyof T]: T[K]};

@UselessPickles
Copy link

@ajafff FYI - I added your NoInfer type to "type-zoo": pelotom/type-zoo#8

@kugacz
Copy link

kugacz commented Nov 21, 2022

How about we use the compiler's deferral of evaluating unresolved conditional types?

type NoInfer<T> = [T][T extends any ? 0 : never];

Idk if this is any better, but a slightly nicer looking version I've come upon looks like this:

type NoInfer<T> = T extends infer S ? S : never;

Unfortunately, these two types are not equivalent:

class ExampleClass<T> {}

type NoInferOk<T> = [T][T extends any ? 0 : never];
type NoInferBad<T> = T extends infer S ? S : never;

export class OkClass<T> {
    constructor(private clazz: ExampleClass<T>, private _value: NoInferOk<T>) {
    }

    get value(): T {
        return this._value; // OK. No TS error.
    }    
}

export class BadClass<T> {
    constructor(private clazz: ExampleClass<T>, private _value: NoInferBad<T>) {
    }

    get value(): T {
        return this._value; // TS Error: Type 'NoInferBad<T>' is not assignable to type 'T'. 'T' could be instantiated with an arbitrary type which could be unrelated to 'NoInferBad<T>'.(2322)
    }    
}

Playground link

@captain-yossarian
Copy link

Just an update - T & { } creates a "lower-priority" inference site for T by design. I would move this from the "definitely don't depend on this" column to the "it's probably going to work for the foreseeable future" column.

@RyanCavanaugh Is it possible to document this behaviour ?

@MikeRippon
Copy link

Just linking to this issue I raised about rest parameter inference, as NoInfer feels like a pretty clean/readable alternative to requiring more complex behaviour from the compiler: #52227

@Andarist
Copy link
Contributor

Andarist commented Feb 6, 2023

I've seen cases when NoInfer "fails to deliver" its promise though. It would be very cool if this would have builtin support in the compiler :p

@Andarist
Copy link
Contributor

Andarist commented Feb 6, 2023

@RyanCavanaugh I wonder if, with the recent additions of type parameter modifiers, it wouldn't be possible to use "usage modifiers" over intrinsic types. What I mean is that this could be implemented using a custom syntax/keyword, like this:

declare function assertEqual<T>(actual: T, expected: noinfer T): boolean;
const g = { x: 3, y: 2 };
assertEqual(g, { x: 3 }); // Error

I also wonder what's the current "status" of this proposal. I understand that the TS team is currently not working on this - but what if I would attempt to implement this? I totally understand that any PR is just a PR and nothing is set in stone until merged (and not even that makes anything set in stone 🤣 ) - but I'm hesitant to implement features that are likely to be rejected.

@dinofx
Copy link

dinofx commented Feb 24, 2023

I don't see why a new keyword would be needed (noinfer). Maybe the glass is half full 😉:

declare function invoke<T, R>(func: (value: infer T) => R, value: T): R;

infer should be unambiguous there, as it isn't part of a conditional extends (related to #52791)

@Andarist
Copy link
Contributor

I started working on this here. Feedback is welcome :)

@Andarist
Copy link
Contributor

Andarist commented Mar 7, 2023

@sandersn has asked me if there are any learnings that I could share based on my PoC implementation of this feature, so here we go:

  1. the implementation is pretty straightforward and I didn't encounter many obstacles
  2. IMHO an intrinsic type is the best approach for this feature, it's flexible and clearly marks part of the type as "blocked" - with it there are no questions about "operator precedence"-like things
  3. It's a powerful tool for declaration authors. Not all libraries would benefit from it but some would. There are a couple of types floating around in the ecosystem that tries to accomplish this. We are using this in XState, I know that RTK is also using this and there are probably more.
  4. I encountered some issues with the custom NoInfer (the one proposed by @jcalz). I don't recall what they were now and what was the exact case but in some complex cases, this technique didn't manage to deliver what it should. Having a built-in would set up clear expectations for the behavior and wouldn't rely on implementation details of things like deferral of evaluating unresolved conditional types
  5. Further things could be explored later (like LowInfer). However, I find the likelihood of that to be quite low. Inferences priorities are also implementation details and a type like this could dangerously leak things to the userland. It's one thing to block the inference completely on given nodes (it doesn't expose much of the internals to the userland) and another thing to give hints about desired inference priorities.

TLDR: TS is insanely expressive already, this would add a new tool to author things that are not always possible today without "hacks" or unintuitive workarounds + the cost of the feature seems to be pretty low to me. What not to like about it? 😉

@dinofx
Copy link

dinofx commented Mar 17, 2023

What happens when NoInfer is used in a place where it does nothing? for example:

function append<T>(dest: T[], src: NoInfer<T[]>) {
  // TODO
}

Where the author intended to do:

function append<T>(dest: T[], src: NoInfer<T>[]) {
  // TODO
}

@Andarist
Copy link
Contributor

My PR doesn't assume that this "does nothing". It blocks the whole type from being used as an inference source. You could intentionally wrap a type that references multiple type parameters with a single NoInfer type.

@Andarist
Copy link
Contributor

I'm not 100% sure about this yet but I also think that the builtin NoInfer could just perform way better (perf-wise) than the one that we have to use today. In sufficiently complex types TS has to "expand" a lot of types by their constraints and explore all branches to find inference candidates. With NoInfer it still has to do that - but it just learns nothing when recursing into types containing it. If a builtin NoInfer could wrap "non-leaf" types then TS could bail out early in certain branches - resulting in a better inference performance.

@jasonkuhrt
Copy link

Just discovered this thread via https://stackoverflow.com/questions/75909231/how-can-this-advanced-function-type-be-achieved/75909852#75909852 where @jcalz enlightened me and pointed out this issue. Thanks for your work @Andarist.

@jp-diegidio
Copy link

jp-diegidio commented Jul 19, 2023

FYI, the following code seems to do the trick without messing up with the declared types: the drawback is that it has a second type parameter, and, though that parameter has a default so that it needn't be specified, it is quite "inelegant" and, IMO, hardly acceptable (maybe because I can't think of a significant use case for it: that we like it or not, the type T is "acquired" as soon as it's (in) the type of any of the arguments, and there is no point in forcing the user to declare what's already there):

function doSomething<T = never, U extends T = T>(t: U) { 
    // do something with `t`
}

type Num = { x: number };

doSomething<Num>({ x: 1 });  // doSomething<Num, Num> => OK

doSomething({ x: 1 })  // doSomething<never, never> => ERROR!

OTOH, a single type parameter T = never does work as long as no argument has T appearing in its type: and this pattern I do have used, since, with the type parameter simply T, if the user is not explicit with the type, s/he gets unknown inferred (or anyway the constraint on T, i.e. B if the type was T extends B), and this may indeed be not acceptable/good enough in some cases...

@jp-diegidio
Copy link

jp-diegidio commented Jul 19, 2023

P.S. Sorry, what I have said above, about a single type parameter T = never doing the job in case no arguments have type T, is just not true, for example consider this code:

// for example, just notice that `T` does not appear in the type of the arguments:
function coercion<T = never>(t: unknown): T { return t as T; }

type Num = { x: number };

coercion<Num>({ x: 1 });   // coercion<Num> => OK

coercion({ x: 1 })   // coercion<never> => OK, returns `never`!

I do have a use case where a plain T = never does the trick, but (after looking at it again) it's the specific way I am using T to construct the return type that guarantees that I get a compiler error in user code when T is never...

@craigphicks
Copy link

craigphicks commented Dec 18, 2023

I also ran into the problem that U extends T allows excess properties as @PyroVortex mentions above.

I found a rather simple work around: {} & T The example above would then be:

declare function invoke<T, R>(func: (value: T) => R, value: {} & T): R;

declare function test(value: { x: number; }): number;

invoke(test, { x: 1, y: 2 }); // Compiler error
test({ x: 1, y: 2 }); // Compiler error

In this formula it is completely feed forward, no inference between variables.

declare function invoke<F extends ((value:any) => any)>(func:F, value: Parameters<F>["0"]): ReturnType<F>;
declare function test(value: { x: number; }): number;
invoke(test, { x: 1, y: 2 }); // Compiler Error
//                          ~
test({ x: 1, y: 2 }); // Same Compiler error
//               ~

@olmobrutall
Copy link

I have just updated a big code base to use generic react components thanks to NoInfer<T>. So far very happy with the feature. I would just suggest to completely hide NoInfer<T> in the tooltip.

image

Type declarations are complicated enough already, and NoInfer<T> is a hint to the compiler from the library creator but doesn't mean anything for the library consumer.

@m93a
Copy link

m93a commented Jun 26, 2024

I just wanted to chime in and say that NoInfer saved me from an unintuitive and unexpected "Type instantiation is excessively deep and possibly infinite." error when using a function call as a prop value in a complex object type. Before using NoInfer, TS wanted to infer a generic parameter from the return type of the function, leading to the error. After I marked the function's return value as NoInfer, the error went away!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet