Skip to content

This typing with intersection types #6452

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

Closed
hoqhuuep opened this issue Jan 12, 2016 · 10 comments
Closed

This typing with intersection types #6452

hoqhuuep opened this issue Jan 12, 2016 · 10 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@hoqhuuep
Copy link

I am unsure if this is a bug, intended behavior or not yet implemented. Please advise.

Simple Example

I am using TypeScript 1.7.5 which has support for "intersection types" and "this typing". In the below code, I would expect the type of v2 to be A & B. However it actually has type A.

class A {
    a: number;
    self() {
        return this;
    }
}

class B {
    b: number;
}

let v1: A & B;

let v2 = v1.self();

More Useful Example

In the code below I would expect d to have type Extendable & B & C. However the type is actually just Extendable & C.

interface Extendable {
    extend<T>(x: T): this & T;
}

interface B {
    b: number;
}

interface C {
    c: number;
}

let a: Extendable;
let b: B;
let c: C;

let d = a.extend(b).extend(c);
@mhegazy
Copy link
Contributor

mhegazy commented Jan 12, 2016

This matches the intended design. this types have the scope of a class/interface, and are not instantiated on every call. This has been discussed before in #4931.

@mhegazy mhegazy added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Jan 12, 2016
@hoqhuuep
Copy link
Author

Thank you very much for the link! I missed that in my search to see if it had been discussed before.

Please forgive my lack of expertise, but I am struggling to understand the explanation given on that linked issue.

The reason is this is a generic parameter of the type that is instantiated by the reference, (which is needed to support property declaration using type this), and not passed at the call site. This does not match the JS call semantics. but can be done along with function declaration this type support.

If you (or anyone else reading this) have time, could you please:

  • explain what is meant by "the type that is instantiated by the reference"?
  • give an example showing why this is required "to support property declaration using type this"?

I should also point out that (as I am sure you are aware) the semantics I desire are possible using non-member functions. In the below code snippets, the variables have the types I expect:

class A {
    a: number;
}

class B {
    b: number;
}

function self_<T>(a: T): T {
    return a;
}

let v1: A & B;

let v2 = self_(v1); // Type of `v2` is `A & B`

And:

interface Extendable {
}

interface B {
    b: number;
}

interface C {
    c: number;
}

function extend<S, T>(self: S, x: T): S & T {
    return;
}

let a: Extendable;
let b: B;
let c: C;

let d = extend(extend(a, b), c); // Type of `d` is `Extendable & B & C`

However, "this typing" appears to be largely motivated by "fluent APIs" and the above code removes this syntactic nicety. Particularly the second example gets ugly quite fast without method chaining.

Do I understand correctly that "function declaration this type support" would allow me to get the fluent API syntax with the semantics I desire? This is a not-yet-implemented feature, right?

Thanks again!

@sandersn
Copy link
Member

I'm working on this-types for functions right now, but it's not done. The proposal at #6018 links to the branch where I'm working on it. To explain why extend needs this feature, let's annotate the invisible this type parameter on Extendable

declare function extend<S, T>(self: S, other: T): S & T;
interface Extendable<this> {
    extend<T>(other: T): this & T;
}

Notice that extend has two parameters S and T, whereas Extendable.extend only has one, T. this is supposed to be equivalent to S to allow you to chain calls. Unfortunately, it's not -- it's bound on interface Extendable instead of the method Extendable.extend. So when you use it, the return value of the first call doesn't get to infer the this type for the second call. Here, I'll annotate the type parameters of the usages explicitly:

interface B {
    b: number;
}
interface C {
    c: number;
}
let a: Extendable<this=Extendable>;
let b: B;
let c: C;
let d1 = a.extend<B>(b); // --> this=Extendable
let d2 = d1.extend<B>(c); // --> this=Extendable, NOT Extendable & B

Notice that calling d1.extend doesn't give us this=Extendable & B because it's the original Extendable<Extendable>.extend method. It's the same method as before.

This-types for functions (#6018) should solve this by letting you specify this per function-call. Notice that the declaration looks even closer to the non-chaining declaration:

interface Extendable {
  extend<S,T>(this: S, other: T): S & T;
}

This type binds the type of the calling object of extends to S, and is inferred fresh for each call to Extendable.extend. This contrasts with the original chaining version that binds this to the type of a and keeps it there.

let d1 = a<A,B>.extend(b);
let d2 = d1<A & B, C>.extend(c);

@hoqhuuep
Copy link
Author

Thank you very much for the explanation! I know have a much better grasp of how the current system works. I am still a bit unsure about why it needs to work this way, rather than having the this type be determined at the method call or property access.

Thank you also for the information about "this-types for functions". I look forward to being able to use them! :-)

@Ciantic
Copy link

Ciantic commented Jul 19, 2016

Intended design does not match the intuition. I too expected the v2 in the original example to be A & B, and I'm now trying to find a workaround.

@Ciantic
Copy link

Ciantic commented Jul 19, 2016

Here is a bit longer use-case, my backend API generates a TypeScript interface that I can use directly in TS with type safety.

This implementation, which I thought was intuitive, is not working:

export interface BasePromise<T> extends PromiseLike<T> {
    onDone(cb: (t: T) => void): this;
}

// This interface is generated from the backend Api errors
export interface ApiErrors {
    onError(errorCode: "ValidationError", cb: (data: { fields : { [k: string]: string[] }, messages : string[] }) => void): this;
    onError(errorCode: "NotFound", cb: (data: null) => void): this;
    onError(errorCode: "NotAuthorized", cb: (data: null) => void): this;
    onError(errorCode: "Forbidden", cb: (data: null) => void): this;
    onError(errorCode: "UndefinedError", cb: (data: null) => void): this;
}

// These are implementation specific errors, not generated from backend
export interface BaseErrors {
    onError(errorCode: "XhrJsonParseError", cb: (data: null) => void): this;
    onError(errorCode: "XhrUnknownContentType", cb: (data: null) => void): this;
    onError(errorCode: "XhrWwwAuthenticationFailed", cb: (data: { error: string, error_description: string }) => void): this;
}

export const request = <T>(path: string, method: "POST", body: Object | null): BasePromise<T> & BaseErrors & ApiErrors => {
    // Implementation omitted
    return null as any;
}

request("some/url", "POST", {})
    .onError("ValidationError", () => {
        // Do on validation error

    // This causes ERROR
    }).onError("XhrJsonParseError", () => {
        // Do on parse error
    })
    .onDone(() => {
        // Do on done
    });

I found a workaround, but it is a mess of intersection types at this, it also makes ApiErrors and BaseErrors bound together, whereas with above they wouldn't have to be:

export interface BasePromise<T> extends PromiseLike<T> {
    onDone(cb: (t: T) => void): this & BaseErrors<T> & ApiErrors<T>;
}

// This interface is generated from the backend Api errors
export interface ApiErrors<T> {
    onError(errorCode: "ValidationError", cb: (data: { fields : { [k: string]: string[] }, messages : string[] }) => void): this & BaseErrors<T> & BasePromise<T>;
    onError(errorCode: "NotFound", cb: (data: null) => void): this & BaseErrors<T> & BasePromise<T>;
    onError(errorCode: "NotAuthorized", cb: (data: null) => void): this & BaseErrors<T> & BasePromise<T>;
    onError(errorCode: "Forbidden", cb: (data: null) => void): this & BaseErrors<T> & BasePromise<T>;
    onError(errorCode: "UndefinedError", cb: (data: null) => void): this & BaseErrors<T> & BasePromise<T>;
}

// These are implementation specific errors, not generated from backend
export interface BaseErrors<T> {
    onError(errorCode: "XhrJsonParseError", cb: (data: null) => void): this & BasePromise<T> & ApiErrors<T>;
    onError(errorCode: "XhrUnknownContentType", cb: (data: null) => void): this & BasePromise<T> & ApiErrors<T>;
    onError(errorCode: "XhrWwwAuthenticationFailed", cb: (data: { error: string, error_description: string }) => void): this & BasePromise<T> & ApiErrors<T>;
}

export const request = <T>(path: string, method: "POST", body: Object | null): BasePromise<T> & BaseErrors<T> & ApiErrors<T> => {
    // Implementation omitted
    return null as any;
}

request("some/url", "POST", {})
    .onError("ValidationError", () => {
        // Do on validation error
    }).onError("XhrJsonParseError", () => {
        // Do on parse error
    })
    .onDone(() => {
        // Do on done
    });

@sandersn
Copy link
Member

If you are using 2.0 beta (by running npm install typescript@beta), can you try the workaround I suggested earlier? You would change the members of ApiErrors and BaseErrors by adding a this parameter:

export interface ApiErrors {
  onError<T>(this: T, ...): T
}

To boil down the problem into a single sentence: if you want a type, even a this type, to vary per function-call, the type parameter has to be bound per-function call. Not per-class.

@Ciantic
Copy link

Ciantic commented Jul 21, 2016

@sandersn yes it works! How come you call this workaround? Isn't this the solution?

Following example can be copy & pasted to the VSCode:

export interface BasePromise<TRes> extends PromiseLike<TRes> {
    onDone<T>(this: T, cb: (t: TRes) => void): T;
}

// This interface is generated from the backend Api errors
export interface ApiErrors {
    onError<T>(this: T, errorCode: "ValidationError", cb: (data: { fields : { [k: string]: string[] }, messages : string[] }) => void): T;
    onError<T>(this: T, errorCode: "NotFound", cb: (data: null) => void): T;
    onError<T>(this: T, errorCode: "NotAuthorized", cb: (data: null) => void): T;
    onError<T>(this: T, errorCode: "Forbidden", cb: (data: null) => void): T;
    onError<T>(this: T, errorCode: "UndefinedError", cb: (data: null) => void): T;
}

// These are implementation specific errors, not generated from backend
export interface BaseErrors {
    onError<T>(this: T, errorCode: "XhrJsonParseError", cb: (data: null) => void): T;
    onError<T>(this: T, errorCode: "XhrUnknownContentType", cb: (data: null) => void): T;
    onError<T>(this: T, errorCode: "XhrWwwAuthenticationFailed", cb: (data: { error: string, error_description: string }) => void): T;
}

export const request = <T>(path: string, method: "POST", body: Object | null): BasePromise<T> & BaseErrors & ApiErrors => {
    // Implementation omitted
    return null as any;
}

request("some/url", "POST", {})
    .onError("ValidationError", () => {
        // Do on validation error
    }).onError("XhrJsonParseError", () => {
        // Do on parse error
    })
    .onDone(() => {
        // Do on done
    });

I also changed my above, not working examples to such they can be pasted to VSCode.

@hoqhuuep
Copy link
Author

Just tried this out in 2.0 beta myself. Very happy with the result! Just need to add the extra type parameter and explicit this parameter, and everything works as expected! Thanks for your work on this @sandersn, unless you see any reason to do otherwise I'm happy for you to close this issue now.

interface Extendable {
    extend<S, T>(this: S, x: T): S & T;
}

interface B {
    b: number;
}

interface C {
    c: number;
}

let a: Extendable;
let b: B;
let c: C;

let d = a.extend(b).extend(c); // 'd' has type 'Extendable & B & C' as desired :-)

@sandersn
Copy link
Member

@Ciantic I called this a workaround because your original solution returning this didn't work — ideally the language would never surprise you and you could just write the program you wanted to write in the first place.

@hoqhuuep good to know it's working as desired. I'll close the issue.

@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants