Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Using rest destructing on class objects seems to include getters in the resulting type #46967

Closed
divillysausages opened this issue Dec 1, 2021 · 11 comments · Fixed by #47078
Closed
Assignees
Labels
Breaking Change Would introduce errors in existing code Bug A bug in TypeScript Fix Available A PR has been opened for this issue

Comments

@divillysausages
Copy link

Bug Report

If you have a class along the lines of:

class User {
    constructor(
        public uid: number,
        public password: string // sensitive info
    ) { }
	
    get score(): number {
        return 10; // example code - imagine having to fetch something a little more complex
    }
}

If we then want to destructure a User object (e.g. returning a client-friendly basic object from a server), we first declare a type:

type UserClient = Omit<User, 'password' | 'score'>;

We pass score to the Omit here because, as we're using destructing, only basic properties will be returned, not any getters. We can then create our method:

toClient(): UserClient {
    const { password, ...client } = this;
    return client;
}

However, score is something that we want to return, so we change our UserClient type to:

type UserClient = Omit<User, 'password' | 'score'> | { score: number };

and our toClient() method now becomes:

toClient(): UserClient {
    const { password, ...client } = this;
    return {
        score: this.score,
        ...client
    }
}

This, however, gives the error 'score' is specified more than once, so this usage will be overwritten, even though when we destructure an object, it doesn't give us the getters - it seems like the inferred type from this line:

const { password, ...client } = this;

is wrong (specifically, it's Omit<this, 'password'> for client). In order to fix it, I need to change that line to

const { password, score, ...client } = this;

This doesn't really have any effect on the underlying JavaScript code, so it's a minor bug (I think)

🔎 Search Terms

  • rest
  • destructuring

🕗 Version & Regression Information

I'm currently on 4.4.3 though running in TypeScript Playground, 3.9.7 is when it first seems to show up.

In that version, the inferred type is Pick<this, Exclude<keyof this, "password">>

In 3.8.3 the inferred type is the same, though the error isn't present, so there must have been a change to the underlying Pick code (perhaps to include getters?)

  • This changed between versions 3.8.3 and 3.9.7

⏯ Playground Link

https://www.typescriptlang.org/play?ts=4.4.4#code/MYGwhgzhAECqEFMBO0DeAoAkMA9gOwgBckBXYQnJACi0wAcSAjEAS2GhJYBMAuaPEgFtGyADS0GzNtDqQIAd0q9oRJCzwBzaAHptKhARaEWANwTR1AMxxYAlGmgBfLFg0JCK3EgRVbfAcLIaLTehCRIeNAAjAAMANw6eggAHmCCdCDmuFzmALQWgmAa6uYAFmAm6loU0JbuwKUqOILupVXQYNCshISZ0IKUWc0ZKVjO6NCT0BQAwqwGhL588MhzLAvBU1vQuAQeqDJyikhcotAAdJeg63gejtAAvNNtEHET25Oh4ZEYHx8QXgQfEIL3OAMG4j+20u52uC3eH2cW2czkIAE86OYVkg1hsngB5QRGAA82LOAHJZFBjlxydAAD7Qcng7zkgB8DIcLKB-CEIhQjjiQA

💻 Code

class User {
    constructor(
        public uid: number,
        public password: string // sensitive info
    ) { }
	
    get score(): number {
        return 10; // example code - imagine having to fetch something a little more complex
    }

    toClient(): UserClient {
        const { password, ...client } = this;
        return {
            score: this.score, // ERROR: 'score' is specified more than once, so this usage will be overwritten
            ...client
        }
    }
}
type UserClient = Omit<User, 'password' | 'score'> | { score: number };

🙁 Actual behavior

The inferred type on client seems to include getters

🙂 Expected behavior

The inferred type on client to only include basic properties as it's created through destructuring

@whzx5byb
Copy link

whzx5byb commented Dec 1, 2021

Workaround: Use the specified class type instead of the polymorphic this type

toClient() {
    const { password, ...client } = this as User 
    return {
        score: this.score, // OK
        ...client
    }
}

@divillysausages
Copy link
Author

@whzx5byb Can you explain that a bit, please? Why is this not already considered a User inside it's own class method?

@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label Dec 1, 2021
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 4.6.0 milestone Dec 1, 2021
@fatcerberus
Copy link

Why is this not already considered a User inside it's own class method?

this is considered generic, i.e. this extends User, because your method may be called through a subclass.

@jakebailey
Copy link
Member

Thanks for giving a version range where this stopped working; I was able to bisect this to #37192.

@jakebailey
Copy link
Member

jakebailey commented Dec 7, 2021

Hm, that PR is actually just what expanded out the error message; the core issue that rest destructuring this doesn't omit properties seems to have existed for much longer (potentially always).

Here's a modified example that shows this behavior:

class User {
	constructor(
		public uid: number,
		public password: string // sensitive info
	) { }
	
	get score(): number {
		return 10; // example code - imagine having to fetch something a little more complex
	}

    toClient(): { uid: number; score?: never } {
        const { password: _, ...client } = this;
        return client; // Type 'this["score"]' is not assignable to type 'undefined'.
    }

    toClient2(): { uid: number; score?: never } {
        const { password: _, ...client } = this as User;
        return client; // OK
    }
}

https://www.typescriptlang.org/play?ts=4.5.2#code/MYGwhgzhAECqEFMBO0DeAoAkMA9gOwgBckBXYQnJACi0wAcSAjEAS2GhJYBMAuaPEgFtGyADS0GzNtDqQIAd0q9oRJCzwBzaAHptKhARaEWANwTR1AMxxYAlGmgBfLFg0JCK3EgRVbfAcLIaLTehCRIeNAAjAAMANw6eggAHmCCdCDmuFzmALQWgmAa6uYAFmAm6loU0JbuwKUqOILupVXQYNCshISZ0IKUWc0ZKVjO6NCT0BQAwqwGhL58qBzc-kIiSAkQXggA-P4IZiiOwVPn0LgEHiuyUIpIygD6otAAdB+gLAtO0AC80zaEDiEwuk1C4UiXwWIPO43Os3meEIACYlg5OMoApttrsDvwjkFThgwZMrkQHHcFEo+C93p8kR5TgDCECOjB4MhYaSIRFLozuZNnM4gA

With the desired behavior being that both toClient and toClient2 have no errors, but as User is the only one that works consistently.

@jakebailey
Copy link
Member

jakebailey commented Dec 9, 2021

I was able to "fix" the original case by not using Omit on this when destructuring, however one can also destructure a value of type T extends SomeClassWithPropGetter and get the same thing. The extended test case is:

class A {
    constructor(public foo: string) {}

    get bar(): number {
        return 1;
    }

    func() {
        const {           ...rest1 } = this;
        const {           ...rest2 } = this as A;
        const { foo: _f1, ...rest3 } = this;
        const { foo: _f2, ...rest4 } = this as A;

        // Rest destructuring drops properties provided by getters.
        // "bar" should not be present in any of these.
        rest1.bar;
        rest2.bar;
        rest3.bar;
        rest4.bar;
    }
}

function destructure<T extends A>(x: T) {
    const {           ...rest1 } = x;
    const {           ...rest2 } = x as A;
    const { foo: _f1, ...rest3 } = x;
    const { foo: _f2, ...rest4 } = x as A;

    // Rest destructuring drops properties provided by getters.
    // "bar" should not be present in any of these.
    rest1.bar;
    rest2.bar;
    rest3.bar;
    rest4.bar;
}

Playground Link

Where all 8 accesses to bar should be errored.

@divillysausages
Copy link
Author

Hi @jakebailey ,

Thanks for looking at this, and getting a fix so quickly.

To be clear, in your last Playground link, it'll error on all 8 access to bar in a future TypeScript version? In v4.5.2, it only errors out on the 4 as A lines for me

@jakebailey
Copy link
Member

jakebailey commented Dec 9, 2021

That's what my PR does, yes. You can try out my PR here: Playground Link

There's unfortunately a little more nuance that my PR needs to handle better, namely, it should probably be legal to destructure non-public properties when it's this of the current class being unpacked; mine disallows this but probably only because I'm reusing the code that previously only ran on the non-generic case, where you're only considering the public interface of a type.

I'll make sure we discuss it before merging.

@jakebailey
Copy link
Member

Per #47078 (comment), dropping non-public seems correct, so the behavior I'm showing is the final one.

@divillysausages
Copy link
Author

Thanks again, @jakebailey and @RyanCavanaugh for the quick turnaround! 👍

@DanielRosenwasser DanielRosenwasser added the Breaking Change Would introduce errors in existing code label Jan 19, 2022
@bergus
Copy link

bergus commented Jan 26, 2022

as we're using destructing, only basic properties will be returned, not any getters.

This seems like an unsafe assumption. Whether a property is copied into a rest pattern depends on whether it is an own property, not on whether a property is a getter or a method. As far as I understand, TypeScript doesn't distinguish own from inherited properties?

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
Breaking Change Would introduce errors in existing code Bug A bug in TypeScript Fix Available A PR has been opened for this issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants