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

fix(pipe): replace rest parameters overload #3945

Merged
merged 15 commits into from
Jul 26, 2018

Conversation

cartant
Copy link
Collaborator

@cartant cartant commented Jul 24, 2018

Description:

IMPORTANT: This PR depends upon the dtslint additions in #3944.


WARNING: This PR uncovered several typing problems in the test suite, so it's likely that it will also uncover typing problems in user code that were previously hidden. Said problems in the test suite could have been hidden for a number of reasons:

  • for a long time, the test suite was not type checked;
  • the initial rest parameters signature could have hidden them; or
  • the rest parameters signature introduced in fix(Observable): Update typings of pipe #3789 would have continued to hide them.

It fixes a problem that was introduced in this commit. Prior to that commit, the pipe overload signature for more than 9 arguments looked like this:

pipe<R>(...operations: OperatorFunction<T, R>[]): Observable<R>;

Which is incorrect, as consecutive arguments have to have matching output/input types - every OperatorFunction passed cannot accept a T and return an R.

Unfortunately, fixing that by changing the signature to the following introduced another problem:

pipe<R>(...operations: OperatorFunction<any, any>[]): Observable<R>;

With that signature, any usage with 9 or fewer arguments that does not match a signature due to type errors will now match the rest parameters signature. So strong typing is subverted and that's the problem highlighted in #3841.

In a comment in this TypeScript issue, Anders Hejlsberg has pointed out the output/input relationship between arguments means that the overloads cannot be avoided. So to restore strong typing, this PR replaces the rest parameters overload signature with a signature that also includes the preceding 9 type-parameter-specified arguments, like this:

pipe<A, B, C, D, E, F, G, H, I, R>(
  op1: OperatorFunction<T, A>,
  op2: OperatorFunction<A, B>,
  op3: OperatorFunction<B, C>,
  op4: OperatorFunction<C, D>,
  op5: OperatorFunction<D, E>,
  op6: OperatorFunction<E, F>,
  op7: OperatorFunction<F, G>,
  op8: OperatorFunction<G, H>,
  op9: OperatorFunction<H, I>,
  ...operations: OperatorFunction<any, any>[]
): Observable<R>;

The effect of this is that it will no longer be possible to specify a single R type parameter for pipe calls that involve more than 9 arguments. Instead, an assertion would need to be used like this:

const result = of("T").pipe(
    mapTo("A"),
    mapTo("B"),
    mapTo("C"),
    mapTo("D"),
    mapTo("E"),
    mapTo("F"),
    mapTo("G"),
    mapTo("H"),
    mapTo("I"),
    mapTo("R")
) as Observable<"R">; // Otherwise inferred to be Observable<{}>

This PR also adds dtslint tests for pipe and the static pipe utility function that include expectations for type inference and errors.

Related issue (if exists): #3841

@cartant
Copy link
Collaborator Author

cartant commented Jul 24, 2018

I'll rebase this PR after #3944 is merged. I've created this separately as I thought that would be better than lumping everything into one PR. It's a pity that dependent PRs aren't a thing.

@ci-reporter
Copy link

ci-reporter bot commented Jul 24, 2018

The build is failing

✨ Good work on this PR so far! ✨ Unfortunately, the Travis CI build is failing as of 8be1b88. Here's the output:

npm test
> @reactivex/rxjs@6.2.2 test /home/travis/build/ReactiveX/rxjs
> cross-env TS_NODE_PROJECT=spec/tsconfig.json mocha --opts spec/support/default.opts "spec/**/*-spec.ts"


/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:250
    return new TSError(diagnosticText, diagnosticCodes)
           ^
TSError: ⨯ Unable to compile TypeScript:
spec/operators/reduce-spec.ts(196,30): error TS2345: Argument of type 'MonoTypeOperatorFunction<"n">' is not assignable to parameter of type 'OperatorFunction<string, "n">'.
  Types of parameters 'source' and 'source' are incompatible.
    Type 'Observable<string>' is not assignable to type 'Observable<"n">'.
      Type 'string' is not assignable to type '"n"'.
spec/operators/reduce-spec.ts(295,12): error TS2345: Argument of type 'MonoTypeOperatorFunction<any[]>' is not assignable to parameter of type 'OperatorFunction<{ a: number; b: string; }, any[]>'.
  Types of parameters 'source' and 'source' are incompatible.
    Type 'Observable<{ a: number; b: string; }>' is not assignable to type 'Observable<any[]>'.
      Type '{ a: number; b: string; }' is not assignable to type 'any[]'.
        Property '[Symbol.unscopables]' is missing in type '{ a: number; b: string; }'.
spec/operators/reduce-spec.ts(325,28): error TS2345: Argument of type 'MonoTypeOperatorFunction<any[]>' is not assignable to parameter of type 'OperatorFunction<number, any[]>'.
  Types of parameters 'source' and 'source' are incompatible.
    Type 'Observable<number>' is not assignable to type 'Observable<any[]>'.
      Type 'number' is not assignable to type 'any[]'.
spec/operators/reduce-spec.ts(330,23): error TS7006: Parameter 'rs' implicitly has an 'any' type.
spec/operators/reduce-spec.ts(383,28): error TS2345: Argument of type 'MonoTypeOperatorFunction<string[]>' is not assignable to parameter of type 'OperatorFunction<number, string[]>'.
  Types of parameters 'source' and 'source' are incompatible.
    Type 'Observable<number>' is not assignable to type 'Observable<string[]>'.
      Type 'number' is not assignable to type 'string[]'.
spec/operators/reduce-spec.ts(389,23): error TS7006: Parameter 'rs' implicitly has an 'any' type.

    at createTSError (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:250:12)
    at getOutput (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:358:40)
    at Object.compile (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:545:11)
    at Module.m._compile (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:430:43)
    at Module._extensions..js (module.js:663:10)
    at Object.require.extensions.(anonymous function) [as .ts] (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:433:12)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Module.require (module.js:596:17)
    at require (internal/module.js:11:18)
    at /home/travis/build/ReactiveX/rxjs/node_modules/mocha/lib/mocha.js:231:27
    at Array.forEach (<anonymous>)
    at Mocha.loadFiles (/home/travis/build/ReactiveX/rxjs/node_modules/mocha/lib/mocha.js:228:14)
    at Mocha.run (/home/travis/build/ReactiveX/rxjs/node_modules/mocha/lib/mocha.js:536:10)
    at Object.<anonymous> (/home/travis/build/ReactiveX/rxjs/node_modules/mocha/bin/_mocha:582:18)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:191:16)
    at bootstrap_node.js:612:3

I'm sure you can fix it! If you need help, don't hesitate to ask a maintainer of the project!


Failed build for b30613e
npm test
> @reactivex/rxjs@6.2.2 test /home/travis/build/ReactiveX/rxjs
> cross-env TS_NODE_PROJECT=spec/tsconfig.json mocha --opts spec/support/default.opts "spec/**/*-spec.ts"


/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:250
    return new TSError(diagnosticText, diagnosticCodes)
           ^
TSError: ⨯ Unable to compile TypeScript:
spec/operators/first-spec.ts(135,30): error TS2345: Argument of type 'MonoTypeOperatorFunction<"d">' is not assignable to parameter of type 'OperatorFunction<string, "d">'.
  Types of parameters 'source' and 'source' are incompatible.
    Type 'Observable<string>' is not assignable to type 'Observable<"d">'.
      Type 'string' is not assignable to type '"d"'.

    at createTSError (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:250:12)
    at getOutput (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:358:40)
    at Object.compile (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:545:11)
    at Module.m._compile (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:430:43)
    at Module._extensions..js (module.js:663:10)
    at Object.require.extensions.(anonymous function) [as .ts] (/home/travis/build/ReactiveX/rxjs/node_modules/ts-node/src/index.ts:433:12)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Module.require (module.js:596:17)
    at require (internal/module.js:11:18)
    at /home/travis/build/ReactiveX/rxjs/node_modules/mocha/lib/mocha.js:231:27
    at Array.forEach (<anonymous>)
    at Mocha.loadFiles (/home/travis/build/ReactiveX/rxjs/node_modules/mocha/lib/mocha.js:228:14)
    at Mocha.run (/home/travis/build/ReactiveX/rxjs/node_modules/mocha/lib/mocha.js:536:10)
    at Object.<anonymous> (/home/travis/build/ReactiveX/rxjs/node_modules/mocha/bin/_mocha:582:18)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:191:16)
    at bootstrap_node.js:612:3

This comment was automagically generated by ci-reporter. If you see a problem, open an issue here.

@cartant
Copy link
Collaborator Author

cartant commented Jul 24, 2018

Hmm. This PR is uncovering a bunch of typing problems in the test suite.

@coveralls
Copy link

coveralls commented Jul 24, 2018

Coverage Status

Coverage decreased (-0.2%) to 96.78% when pulling 65d12f7 on cartant:issue-3841 into e73881f on ReactiveX:master.

function a<I, O extends string>(inputOrOutput: I | O, output?: O): OperatorFunction<I, O> {
return mapTo<I, O>(output === undefined ? inputOrOutput as O : output);
}

Copy link
Member

@benlesh benlesh Jul 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we actually just hit an issue where the following broke in one of our apps here at Google:

const customOperator = () => <T>(a: Observable<T>) => a;

of('string').pipe(customOperator());

NOTE: customOperator is () => <T>(source: Observable<T>) => Observable<T> NOT <T>() => (source: Observable<T>) => Observable<T>, which seems to work fine.

Can we add a test for that here too?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I narrowed it down to our use of 'noStrictGenerics' flag, which we should be using anyways. Anyways, good idea to add a test for it, though I expect it to just pass in your setup.

@cartant
Copy link
Collaborator Author

cartant commented Jul 25, 2018

@benlesh I've added a test (and moved some files that weren't in the renamed directory). The test is at the bottom of the file:

it('should support operators that return generics', () => {
  const customOperator = () => <T>(a: Observable<T>) => a;
  const o = of('foo').pipe(customOperator()); // $ExpectType Observable<string>
});

@@ -318,7 +318,7 @@ export class Observable<T> implements Subscribable<T> {
* .subscribe(x => console.log(x))
* ```
*/
pipe<R>(...operations: OperatorFunction<T, R>[]): Observable<R> {
pipe<R>(...operations: OperatorFunction<any, any>[]): Observable<R> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to consider here is pipe<R=any>, so that you don't over-rely on return type for inference.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rkirov Can the inference really bubble out of the implementation? I'll take your word for it, as I'm unclear on the actual inference rules. (If you know of any resources - other than the TypeScript source - that explain the workings of TypeScript's type inference, I'd really appreciate if you could direct me to them.)

Anyway, I'm not sure that R is even required here, shouldn't we use something like this?

pipe(...operations: OperatorFunction<any, any>[]): Observable<any>

With the any type parameters already being specified in the rest-parameters type and with it being the implementation of an overloaded function, does R really offer any value whatsoever?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right about "bubbling out", my comment was pertaining to line 299, but I got mixed up. The types here are only relevant for 'implementation', not for the consumer.

About learning how type inference works - TS doesn't maintain a spec any longer, so the best one can do if they want to learn the rules is keep testing with small examples (what I do) or read the source code (haven't done that for type inference).

What makes matters hard here is that this is mixing generics and function overloads. Separately each one has odd corners, and together things are twice as hard.

Historically, we (team maintaining TS in google) have been having a lot of issues with generics that are only inferred through the return type of a function - simplest example being f<T>(): T. TS will try to use an context type to pick T, but sometimes there will be no such type to pick, so it will fallback to {}.

What happens is that in simple cases everything works fine:

const x: string = f();  // T is inferred to be string
const y = f();  // y is {}, which is ok, its an opaque object.

But what we see the inferred {} can be propagating too much, and into another loosely typed API.

function g() {
  return f();
}
function someAPI(x: any) {...}

someAPI(g());

Now, if the maintainer tries to change someAPI to be stricter say 'x: string', they are confronted with a {} that flowed through without anybody noticing, which makes it hard to change. That's why we are considering whether it would have been better if the fallback for when TS cannot infer the generic type should have been any instead of the default {} (technically it is the lower bound, i.e whatever is after extends). Paradoxically, even though any is less safe, it can help making other type change stricted.

Maybe it would have been better if TS forced people to write a type like f<Something>, instead of fallback on {}.

We are still figuring out what is most ergonomic for ppl, would love to hear your opinion too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would have been better if TS forced people to write a type like f<Something>, instead of fallback on {}.

Yes. I agree wholeheartedly with this.

@@ -296,7 +296,7 @@ export class Observable<T> implements Subscribable<T> {
pipe<A, B, C, D, E, F, G>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>, op4: OperatorFunction<C, D>, op5: OperatorFunction<D, E>, op6: OperatorFunction<E, F>, op7: OperatorFunction<F, G>): Observable<G>;
pipe<A, B, C, D, E, F, G, H>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>, op4: OperatorFunction<C, D>, op5: OperatorFunction<D, E>, op6: OperatorFunction<E, F>, op7: OperatorFunction<F, G>, op8: OperatorFunction<G, H>): Observable<H>;
pipe<A, B, C, D, E, F, G, H, I>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>, op4: OperatorFunction<C, D>, op5: OperatorFunction<D, E>, op6: OperatorFunction<E, F>, op7: OperatorFunction<F, G>, op8: OperatorFunction<G, H>, op9: OperatorFunction<H, I>): Observable<I>;
pipe<R>(...operations: OperatorFunction<any, any>[]): Observable<R>;
pipe<A, B, C, D, E, F, G, H, I, R>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>, op4: OperatorFunction<C, D>, op5: OperatorFunction<D, E>, op6: OperatorFunction<E, F>, op7: OperatorFunction<F, G>, op8: OperatorFunction<G, H>, op9: OperatorFunction<H, I>, ...operations: OperatorFunction<any, any>[]): Observable<R>;
Copy link
Collaborator Author

@cartant cartant Jul 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rkirov Regarding our conversation that followed your comment, do you think it would be preferable for the signature with the rest parameters to not include R and to return Observable<any>?

With ten (!) type parameters, it really isn't expected that the caller will make them explicit, which means the return type will be Observable<{}>.

Or should the signature use R, but provide a default: R = any?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@evmar and I were just chatting about the options here. Here is the total space of options (simplified to only talk about the return type).

// using {} (could be changed to unknown post TS 3.0)
declare function f1<T>(): T;
declare function f2(): {};
declare function f3<T={}>(): T;

// using any
declare function g1(): any;
declare function g2<T=any>(): T;

He convinced me that picking up 'any' without ever writing it explicitly in their user code is a bad idea. Also, f1 and f3 are equivalent (at least I haven't gotten an example where they can be separated). So what's left is f1 and f2. Evan's point (and I tend to agree) is that f2 is more explicit because:

let x: string = f1(); // ok
let y: string = f2(); // error, needs an 'as' cast
let z = f2() as string;  // ok

So our suggestion is f2(), i.e. inline Observable<{}> and drop the R. The downside is the syntactic connivence of f<string>() vs f() as Observable<string>. Thankfully, the 'as' casts are expressions like any other, so they can be used anywhere, and we are just talking about syntactic preference.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. That makes sense.

@cartant cartant force-pushed the issue-3841 branch 2 times, most recently from 74c17d4 to 87097f4 Compare July 25, 2018 21:15
@cartant
Copy link
Collaborator Author

cartant commented Jul 25, 2018

@benlesh As discussed with @rkirov, the R type parameter has been removed so that OperatorFunction<T, {}> is returned and an assertion is required. And a test has been added for this.

Note that this makes it even more likely that there will be typing issues exposed in users' code when/if a release including this PR's changes is published.

@cartant cartant changed the title fix(pipe): replace rest parameters overload [depends on PR #3944] fix(pipe): replace rest parameters overload Jul 26, 2018
@benlesh benlesh merged commit 872b0ec into ReactiveX:master Jul 26, 2018
@benlesh
Copy link
Member

benlesh commented Jul 26, 2018

Solid add!

@lock lock bot locked as resolved and limited conversation to collaborators Aug 25, 2018
@cartant cartant deleted the issue-3841 branch September 24, 2020 07:07
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants