镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Suggestion: Noninferential type parameter usage #14829

Description

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

Activity

  1. mhegazy commented on Mar 24, 2017

    @mhegazy
    Contributor

    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;
  2. johnfn commented on Mar 24, 2017

    @johnfn

    Mohamed Hegazy (@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.

  3. mhegazy commented on Mar 24, 2017

    @mhegazy
    Contributor

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

  4. PyroVortex commented on Mar 25, 2017

    @PyroVortex

    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
  5. added
    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this feature
    and removed on May 1, 2017
  6. RyanCavanaugh commented on May 1, 2017

    @RyanCavanaugh
    MemberAuthor

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

  7. ajafff commented on May 1, 2017

    @ajafff
    Contributor

    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
  8. RyanCavanaugh commented on May 1, 2017

    @RyanCavanaugh
    MemberAuthor

    Klaus Meinhardt (@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.

  9. ajafff commented on May 3, 2017

    @ajafff
    Contributor

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

    Ryan Cavanaugh (@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.

  10. jwbay commented on Jun 12, 2017

    @jwbay
    Contributor

    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;
  11. RyanCavanaugh commented on Aug 7, 2017

    @RyanCavanaugh
    MemberAuthor

    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.

  12. 21 remaining items

  13. Andarist commented on Feb 6, 2023

    @Andarist
    Contributor

    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

  14. Andarist commented on Feb 6, 2023

    @Andarist
    Contributor

    Ryan Cavanaugh (@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.

  15. dinofx commented on Feb 24, 2023

    @dinofx

    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)

  16. Andarist commented on Feb 25, 2023

    @Andarist
    Contributor

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

  17. Andarist commented on Mar 7, 2023

    @Andarist
    Contributor

    Nathan Shively-Sanders (@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 Joe Calzaretta (@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? 😉

  18. dinofx commented on Mar 17, 2023

    @dinofx

    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
    }
  19. Andarist commented on Mar 17, 2023

    @Andarist
    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.

  20. Andarist commented on Mar 31, 2023

    @Andarist
    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.

  21. jasonkuhrt commented on Apr 2, 2023

    @jasonkuhrt
  22. jp-diegidio commented on Jul 19, 2023

    @jp-diegidio

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

  23. jp-diegidio commented on Jul 19, 2023

    @jp-diegidio

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

  24. craigphicks commented on Dec 18, 2023

    @craigphicks

    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
    //               ~
    
  25. olmobrutall commented on Feb 15, 2024

    @olmobrutall

    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.

  26. cshaa commented on Jun 26, 2024

    @cshaa

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions