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

Improve inference in the presence of context-sensitive expressions #47599

Open
5 tasks done
RyanCavanaugh opened this issue Jan 25, 2022 · 12 comments Β· Fixed by #48538
Open
5 tasks done

Improve inference in the presence of context-sensitive expressions #47599

RyanCavanaugh opened this issue Jan 25, 2022 · 12 comments Β· Fixed by #48538
Labels
Experimentation Needed Someone needs to try this out to see what happens Help Wanted You can do this Suggestion An idea for TypeScript
Milestone

Comments

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jan 25, 2022

Suggestion

πŸ” Search Terms

contextually context-sensitive generic inference unused parameter method object literal

βœ… Viability Checklist

My suggestion meets these guidelines:

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

⭐ Suggestion

When generic inference is gathering candidates, context-sensitive expressions should still be considered if it's possible to do so without observing a type parameter from the call.

πŸ“ƒ Motivating Example

declare function callIt<T>(obj: {
    produce: (n: number) => T,
    consume: (x: T) => void
}): void;

// Works
callIt({
    produce: () => 0,
    consume: n => n.toFixed()
});

// Fails for no obvious reason
callIt({
    produce: _a => 0,
    consume: n => n.toFixed()
});

// Fails for no obvious reason
callIt({
    produce() {
        return 0;
    },
    consume: n => n.toFixed()
});

πŸ’» Use Cases

Background: expressions can be "context-sensitive" for the purposes of inference -- this is a syntactic property of an expression that tells us whether or not an expression's type could be dependent on the inference of its contextual type. For _a => 0, this is true because the return expression might depend on _a (including in indirect ways). For produce() {, it's context-sensitive because (unlike an arrow function) it takes its parent object type for this, so could depend on the type parameter via a reference of this.

When it turns out that the type of the expression is not actually dependent on the inference of the type parameter (which would represent a true circularity, thus more understandably non-working), then this just looks busted for no obvious reason. This is a continuous source of surprise and annoyance. (TODO: link user reports)

Discussion

There are a few implementation strategies we could try, with varying trade-offs

  • Proceed to do structural inference on context-sensitive expressions but mark the type parameters symbols as "off limits" with logic to back out. This is likely impractically invasive
  • Perform a more robust check to determine "true" context-sensitivity -- for example, produce: _a => 0 should not be contextually sensitive because the relational target (n: number) => T does not use T in a covariant position. There are multiple ways we could do this
  • Add an optional intermediate pass when inference collected no candidates when ignoring context-sensitive expressions where we go and collect from them anyway. This pass could safely fix parameters since they'd be going to unknown. This entire bullet point might be wrong.
  • As a stopgap, consider function expressions to not be context-sensitive if all of their return expressions can be trivially seen to not depend on earlier lines of code (e.g. are literals, literals of literals, null, or undefined). This might fix a surprisingly large class of reports
  • As a stopgap, consider an object literal method to not be context-sensitive if it doesn't reference this. I believe we already have code for this and it would fix another large class of surprises.
@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Help Wanted You can do this Experimentation Needed Someone needs to try this out to see what happens labels Jan 25, 2022
@RyanCavanaugh RyanCavanaugh changed the title Improve inference in the presence of contextually-sensitive expressions Improve inference in the presence of context-sensitive expressions Jan 25, 2022
@ahejlsberg ahejlsberg self-assigned this Apr 1, 2022
@typescript-bot typescript-bot added the Fix Available A PR has been opened for this issue label Apr 3, 2022
@ahejlsberg ahejlsberg added this to the TypeScript 4.7.1 milestone Apr 3, 2022
@ahejlsberg
Copy link
Member

Fix now available in #48538.

@RyanCavanaugh
Copy link
Member Author

Not all these cases work yet, so reopening

@RyanCavanaugh RyanCavanaugh reopened this Jun 21, 2022
@ShayDavidson
Copy link

Might be related to my issue: #49951, which still happens in 4.7/4.8

@stephenh
Copy link

stephenh commented Sep 10, 2023

πŸ‘‹ I believe most of the fixes for this have focused on inference across lambdas/keys "at the same level" within the AST; for my use case, I'd like inference to be pushed "up" (or "down"?) chained method calls, i.e. in something like:

// Create a Zod-style fluent DSL for building config
const c = {
  value<V>(): ValueConfig<V> {
    return null!;
  },
};

type Config<T> = { [P in keyof T]: ValueConfig<T[P]> };

function configure<T>(object: Config<T>): void {}

// Add fluent methods to `c.value()`
interface ValueConfig<V> {
  req(): ValueConfig<V>;
  readOnly(): ValueConfig<V>;
  validate(fn: (value: V) => boolean): ValueConfig<V>;
}

// A pojo
type Author = { name: string; address: string; city: string  };

// Try and configure the author
configure<Author>({
  // correctly inferred as c.value<string> --> ValueConfig<string>
  name: c.value(),
  // inferred only as c.value<unknown> --> ValueConfig<unknown>
  address: c.value().req(),
  // compile error, v is implicitly typed as any
  city: c.value().validate((v) => v.length > 0),
});

Ideally all three c.value<V>s would be inferred as V = string, so that any chained/fluent methods that come next are appropriately typed.

Is this use case appropriate for this issue? Thanks!

@Ryuurock
Copy link

2023-10-13.15.09.40.mov

playground

@twiddler
Copy link

I reckon this causes this playground's issue, too? πŸ€”

@winstxnhdw
Copy link

winstxnhdw commented Mar 13, 2024

I noticed that these issues always contain anonymous functions. I am getting the same issue using simple generic types. You can see it in this playground.

@martaver
Copy link

I've managed to conjure another example of this: https://tsplay.dev/mxpM7m

Referencing my SO issue and subsequent discussion for completeness.

@Andarist
Copy link
Contributor

The inference source for R can only be discovered in the second inference pass today (the one that starts including context-sensitive nodes). The compiler never gets to it though.

The intermediate argument's type ({ x: {}; y: Fn<Foo>[]; }) gets rejected as it doesn't match the parameter type ({ x: (bar: Foo) => Base; y?: Fn<Base>[] | undefined; }) of the intermediate signature. Note that {} in x is the "anyFunctionType", that's an internal kind of any used as a placeholder in the first inference pass.

The problem is that the first pass will always use constraints as the fallback for the inferred types. Since - at that point in time - no candidate for R has been observed it gets instantiated as Base and the constraint of F gets instantiated as Fn<Base>. That isn't satisfied by Fn<Foo> that you pass there.

Do you even need F to be a type parameter? It doesn't appear in the return type of your Baz and that's usually a sign that it shouldn't be a type parameter.

You can work around this if you make an inference for R discoverable earlier using intersections:

interface Base {
  x: string;
}
interface Foo extends Base {
  y: number;
}

type Fn<T> = (t: T) => void;
declare const FooFn: Fn<Foo>;
declare const FooBase: Fn<Base>;
declare const FooExtra: Fn<Foo & { extra: string }>;

type Baz = <R extends Base, F extends Fn<R>>(arg: {
  x: (bar: Foo) => R;
  y?: (F & Fn<R>)[];
}) => R;

declare const baz: Baz;
const R1 = baz({ x: (ctx) => ctx });
const R2 = baz({ x: (ctx) => ctx, y: [FooFn] });
const R3 = baz({ x: (ctx) => ctx, y: [FooBase] });
const R$ = baz({ x: (ctx) => ctx, y: [FooExtra] }); // error (as expected, although the location of the error could be better)

@martaver
Copy link

Yeah, in a real world scenario, the type(s) of F would later be used to constrain other types.

The use of an intersection type's a great trick! Very handy to know...

One limitation, though, if I actually alter the return type of y then the compiler is back to expecting Base as the arg for it.

Here's an example where the return type is a union: https://tsplay.dev/N97gMW

I wonder if Fn<Base> is chosen because it's the narrowest common type between the return types, compiler trying to resolve Fn<Foo | Bar>... as opposed to Fn<Foo> | Fn<Bar>... forcing TS to distribute the Return type with R extends Base ? Fn<R> : never doesn't seem to change anything.

@Andarist
Copy link
Contributor

Yeah, I don't have an answer to that πŸ˜‰

An interesting experiment to run would be not ignoring the inferences that could be made from this anyFunctionType. If we'd infer any the signature applicability check could pass and the overall inference could improve in the second pass. I don't know when I'd find the time to play around with this though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experimentation Needed Someone needs to try this out to see what happens Help Wanted You can do this Suggestion An idea for TypeScript
Projects
None yet