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

Add tags for the family of Observable* types to simplify typechecking #1474

Closed
wants to merge 5 commits into from

Conversation

vladima
Copy link
Contributor

@vladima vladima commented Mar 15, 2016

Currently shape of Observable class and its derived classes is quite complicated. As a consequence it takes a long time for a TypeScript compiler to determine relations between them based on only its structure. This PR adds a unique tag property for every class, type of property reflects the set of type parameters of the class. Presence of these properties has following effects:

  • compiler can quickly determine if type A is not a subtype of B if A does not have tag for B
  • in case if A is a subtype of B use types of tags to check relations between type arguments.

I've used this example to measure the actual impact of this change.

# rxjs-5.0.0-beta-2
Files:              60
Lines:           20139
Nodes:           96348
Identifiers:     37452
Symbols:       2052011
Types:          330447
Memory used:   270717K
I/O read:        0.03s
I/O write:       0.02s
Parse time:      0.19s
Bind time:       0.11s
Check time:      4.73s
Emit time:       0.03s
Total time:      5.06s
#rxjs-taggedTypes
Files:             231
Lines:           22169
Nodes:          101982
Identifiers:     39340
Symbols:       6061714
Types:            9638
Memory used:    39202K
I/O read:        0.03s
I/O write:       0.00s
Parse time:      0.31s
Bind time:       0.17s
Check time:      0.48s
Emit time:       0.02s
Total time:      0.98s

Notes:

  • tag fields are preserved in .d.ts files but don't have any runtime impact since they are not initialized.
  • this pattern is broken
class A {
    f: EventEmitter<number> = new EventEmitter(); // was ok, now error since `{}` is not assignable to `number`
}

fix is fairly trivial

class A {
    f = new EventEmitter<number>(); // or f: EventEmitter<number> = new EventEmitter<number>();
}

Reason: since type parameter appear only in function parameters they were treated as bivariant (A<T1> is assignable toA<T2> if either T1 is assignable to T2 or T2 is assignable to T1 ) which is technically unsafe.

class A<T> {
    f: ( fn: (x: T) => void ) => void;
    set(a: T): A<T> {
        this.f = f => f(a);
        return this;
    }

    run(f) {
        this.f(f)
    }
}

let a: A<number> = new A().set("123");
a.run(x => x.toFixed()); // 0 compiler errors but will fail in runtime since actual type of 'x' is string

Tags keep only covariant step (A<T1> is assignable toA<T2> if T1 is assignable to T2) so now these code will be rejected by the compiler.

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

// cc @mhegazy

@david-driscoll
Copy link
Member

@Blesh and @kwonoj will probably want you to squash to one commit 😄

With this change things like the following will work as expected?

let o: Observable<number> = new Subject<number>();

It's only scenarios where you do not define the input type as part of the constructor, for example:

let o: Observable<number> = new Subject();

That makes sense, because we're "pretending" T is a property.

Only question I have is would there be any benefit / negative of using the interface syntax here instead? (Besides it being a little more verbose)

export interface FromEventObservable<T, R> {
    ' tag_class_FromEventObservable': [T, R];
}
export class FromEventObservable<T, R> extends Observable<T> {
    ...

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

  1. yes, let o: Observable<number> = new Subject<number>(); should work
  2. with interfaces this become much more invasive - this property needs to be repeated in all classes that implement the interface. This includes user classes, so this will be yet another breaking change (and I guess more difficult to explain to end user)

@david-driscoll
Copy link
Member

Fair enough, I just wasn't sure if it would be a plus or a minus (sounds like a minus!)

@benlesh
Copy link
Member

benlesh commented Mar 15, 2016

This includes user classes, so this will be yet another breaking change (and I guess more difficult to explain to end user)

... for users that happen to be using TypeScript. Everyone else will be fine. It sounds okay.

Two things I can see:

  • I think we should add comments everywhere these exist that explain what they're for
  • We can flatten this to one commit. The commit message needs to outline the breaking change with a separate line like BREAKING CHANGE: blah blah blah.

Other than that, it looks good. Thanks for generous contribution @vladima

@jayphelps
Copy link
Member

@vladima just to clarify, this speeds up compilation because the key starts with a space and when structurally comparing the shapes, TypeScript does so by comparing the properties sorted alpha-numerically + spaces, so including a space as the first characters of these tag properties, " tag_class_Observable" the tag is compared first so the checker can abort quickly?

@jayphelps
Copy link
Member

In pseudo code: (I realize the typesystem works on a meta level, so it's not runtime like this)

function typesAreEqual(a, b) {
  const keys = keys(a)
    .concat(
      keys(b)
    )
    .removeDuplicates()
    .sort();

  keys.forEach(key => {
    if (typeof a[key] === typeof b[key]) {
      return false;
    }
  });

  return true;
}

class A {
  ' first': number;
  second() {}
  third() {}
}

class B {
  ' first': boolean;
  second() {}
  third() {}
}

let a = new A();
let b = new B();

typesAreEqual(a, b);
// false

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

compiler processes properties in declaration order. Space in the name is a (kind of hacky) way to hide this property in the completion list - currently we don't include members with such names in the completion list.

@benlesh
Copy link
Member

benlesh commented Mar 15, 2016

@vladima and @mhegazy ... would it help if instead of doing this fix, we actually had all methods that would normally accept an Observable to accept an ISubscribable instead?

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

what is the expected shape of ISubscribable?

@benlesh
Copy link
Member

benlesh commented Mar 15, 2016

@vladima something like:

interface ISubscribable<T> {
  subscribe: (observer: Observer<T>) => Subscription;
}

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

so ObservableInput<T> will look like export type ObservableInput<T> = ISubscribable<T> | Promise<T> | ArrayOrIterator<T>; ?

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

yes, it works. I've made this change and tried repro cases that we have - results are just marginally worse than with tagged types.

@vladima
Copy link
Contributor Author

vladima commented Mar 15, 2016

#1475 introduces Subscribable interface that is used instead of Observable in ObservableInput.

@robwormald
Copy link
Contributor

cc @IgorMinar as this will be a breaking change on our end if i'm reading @vladima's OP properly

@vladima
Copy link
Contributor Author

vladima commented Mar 16, 2016

I think that this PR will be closed in favor of #1475 that does not introduce any breaks

@benlesh
Copy link
Member

benlesh commented Mar 17, 2016

Yup, closed in favor of #1475

@lock
Copy link

lock bot commented Jun 7, 2018

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@lock lock bot locked as resolved and limited conversation to collaborators Jun 7, 2018
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.

5 participants