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

Keyword for inferring a new type from destructured parameters #55908

Open
5 tasks done
mausworks opened this issue Sep 29, 2023 · 8 comments
Open
5 tasks done

Keyword for inferring a new type from destructured parameters #55908

mausworks opened this issue Sep 29, 2023 · 8 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@mausworks
Copy link

mausworks commented Sep 29, 2023

🔍 Search Terms

destructuring, destruct, infer destruct, infer destructuring, function destruct, function destructuring

Issue #42419 (+ #48220) discusses changing the current behavior of TypeScript to address the same underlying issue.

My proposal is different as it introduces a new explicit language feature and does not change any current behavior.

I also found #7576. The essence here is that they want some sort of shorthand for destructured parameter types.

I think this is vaguely related to my proposal since it addresses a way to do that—it touches on a similar pain point.

✅ Viability Checklist

  • 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 our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals

⭐ Suggestion

Let's introduce a feature that allows for TypeScript to infer a new type from destructured function parameters.

To conceptualize this idea, I'm introducing a new keyword called from which can be used after : when destructuring a function parameter:

type Person = { first: string, last: string, age: number };

const isAdult = ({ age }: from Person) => age > 18;

This declares that typeof isAdult is (person: Pick<Person, "age">) => ... instead of (person: Person) => ... meaning that any object matching { age: number } can be passed in as a parameter.

You could also potentially do this when destructuring a local variable, though, I struggle to see its usefulness:

const { age, first }: from Person = person;

I'm not even sure whether this should be legal.

📃 Motivating Example

We often use destructuring in functions, because it helps us stay DRY:

type Person = { first: string, last: string, age: number };

const formatName = ({ first, last }: Person) => `${first} ${last}`;
const isAdult = ({ age }: Person) => age > 18;

I think this is good practice and a sensible thing to include in your company style guide.

But if we look critically at the above snippet, we can see that the signatures for both formatName and isAdult are lying about their implementation. They (quite excessively) declare that they need all properties from Person when they actually only need a few. And rightly so: I have explicitly declared that by writing : Person, but my intention here was actually to be able to pass any object that matches { age: number } into isAdult (for example).

To solve this, I have to jump through some hoops, either like this …

type Person = { first: string, last: string, age: number };

const isAdult = ({ age }: Pick<Person, "age">) => age > 18;
const formatName = ({ first, last }: Pick<Person, "first" | "last">) => `${first} ${last}`;

... or the inverse:

type Named = { first: string, last: string };
type Aged = { age: number; }
type Person = Named & Aged;

const formatName = ({ first, last }: Named) => `${first} ${last}`;
const isAdult = ({ age }: Aged) => age > 18;

However…

… the first solution is noisy and un-DRY since have to write the "pick part" twice: first for the destructuring itself, and then for the Pick type. Note: this effect is greatly exacerbated as the number of properties of a type increases, so this example is quite tame in that regard.

… the second solution (workaround?) feels too verbose for my use case (there's too much detail), I have no intent of re-use for the Aged or Named type. If the Person type comes from a third-party library (or another source that I don't control, e.g. Prisma), then this option is out of the question entirely as I can't "build up" my own type from smaller fragments (i.e. Person = Aged & Named).

This means that the first solution is often the only viable option—and it just isn't a great experience.

So—with the addition of the from keyword (or something similar), this headache is entirely relieved! It would make it much more convenient to declare the explicit requirements of a function to effortlessly make your code more expressive.

💻 Use Cases

  1. What do you want to use this for?

This would have great utility in React components where you just want to pick a few props out of e.g. ButtonProps from @mui/material/Button:

function FooterButton({ color, href, children }: from ButtonProps)

However, I see many use cases for this: Anywhere where you need more generically defined function type signatures without having to create a new explicit type or use Pick/Omit.

  1. What shortcomings exist with current approaches?

You need to manually use Pick/Omit or break types into smaller (often hard-to-name) types and compose them later on, or just live with the "excessive" type definitions.

  1. What workarounds are you using in the meantime?

The above 👆

@nopeless
Copy link

from is already a keyword, so I am guessing that some parts of the esm parser have to be rewritten.

Aside from that, my opinion is that if certain properties can be by themselves (i.e. first and last name) they should be properly named interfaces.

Typehints would require some modifications as it has to show the notion of "this is a subset of type Person, with only these properties in consideration"

A good case for this would be to improve duck typing experience and testing.

I don't see how its harmful as long as the developer fully understands the implications

@mausworks
Copy link
Author

mausworks commented Sep 29, 2023

from is already a keyword, so I am guessing that some parts of the esm parser have to be rewritten.

Indeed, I just used from here as an illustrative example, suggestions for a different keyword are absolutely welcome and encouraged—I'm just hoping to spark some discussion for a future feature. The from keyword only seems to be reserved in certain contexts though. 🤔 Edit: You mean in like import-statements, and such, of course! 🤦 Yeah, that could for sure be an issue!

Some alternative names could be: { age } outof Person, { age } picks Person, { age } extends Person (though that is already being used for a lot of things), { age } reduces Person (opposite of extends). We could also go with some sort of prefix: only { age }: Person.

Aside from that, my opinion is that if certain properties can be by themselves (i.e. first and last name) they should be properly named interfaces.

In ideal scenarios, I agree with this to 100%. But there are cases where being this explicit doesn't really matter (like in the above examples), or where you don't control the source of the type (e.g. external lib/generated). But the point of having this keyword (or something similar) is to have the ability to opt-in to this where it makes sense; I agree that this is not always the case!

A good case for this would be to improve duck typing experience and testing.
I don't see how its harmful as long as the developer fully understands the implications

Awesome, thanks for your feedback!

@castarco
Copy link

castarco commented Sep 30, 2023

If TS was not tied to JS, I would think that this is a good idea. But I suspect that it could cold make things more difficult to keep a good level of compatibility with thew new iterations of ECMAScript.

One example that comes to my mind is this RFC: https://github.com/tc39/proposal-type-annotations . My intuition tells me that using a new keyword (instead of :) between the parameter definition and its assigned type can complicate things too much.

If we use a keyword of some sort, I suggest to place it after :.

Regarding "prefixes" (as in the case of only), the same problem applies. I am aware that TS already uses some of them, such as readonly, public or private, but I'm sure there are already some regrets, and I wouldn't be surprised if TS adapted to conform to that TC39 RFC.

@mausworks
Copy link
Author

mausworks commented Sep 30, 2023

(...) One example that comes to my mind is this RFC: https://github.com/tc39/proposal-type-annotations . My intuition tells me that using a new keyword (instead of :) between the parameter definition and its assigned type can complicate things too much.

Fair point. We definitely need to make sure that this is something that is opt-in and takes into consideration upcoming changes to the language. I'll see what I can find on the horizon, if any more examples come to mind, let me know!

If we use a keyword of some sort, I suggest to place it after :.

I think I agree with this, but I would love some more feedback on naming/structure before updating the OP.

Regarding "prefixes" (as in the case of only), the same problem applies. I am aware that TS already uses some of them, such as readonly, public or private, but I'm sure there are already some regrets, and I wouldn't be surprised if TS adapted to conform to that TC39 RFC.

Thanks for your feedback, I'll make sure to read a bit more on TC39

@Dimava
Copy link

Dimava commented Sep 30, 2023

My mind says the world here should be "satisfies"
Considering there should be : for types-in-JS compatibility purposes, it'd look like:

const formatName = ({ first, last }: satisfies Person) => `${first} ${last}`;

This would not work for the variable-destructor case though

const { first, last } = p satisfies Person; // requires it to actually be a Person

@castarco
Copy link

@Dimava satisfies is already used in TypeScript, and has a very different meaning https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator

@mausworks
Copy link
Author

@Dimava: My mind says the word* here should be "satisfies"

I was thinking along the same lines, but it's not really in line with how the keyword is used today. satisfies tends to mean that "this type satisfies all the constraints of type T`, but in our particular case it's more like "these properties are picked out of this object".

The discussion thus far has been great—thanks to everybody who's been participating! 🙌

So far, this is what I have gauged from the discussion (here and on Reddit):

  • People seem to be positive towards the idea or think it would be a useful feature
  • I'm not the only one to experience this as an inconvenience/pain point (see other issues)
  • There needs to be more discussion WRT on how this feature should be implemented
  • The :-symbol should probably stay (WRT TC39, etc.) (I'll update the OP now to include it).

Aside from "what to call this keyword" (if from is out of the question), I guess another open question is whether this should work for local variables too—not just function parameters—what do you think?

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Oct 2, 2023
@Yona-Appletree
Copy link

It would be nice for this to be possible when the destructured argument's type is inferred. Consider my use case:

function defineAction(fn: (context: Context) => any) {
  return fn;
}

// Note that we don't need a colon or type for the argument, since it's inferred
const firstAction = defineAction(({service1}) => {
  service1.doWork1()
})

function testFirstAction() {
  const service1 = mockService1();

  // Ideally I would only need to pass in service1 here, but I have to pass the whole context
  firstAction({ service1 })
}

// Even better, I might like to only need a subset of service1, since that's all I use
const secondAction = defineAction(({service1: { doWork1 }}) => {
  doWork1()
})

function testSecondAction() {
  const doWork1 = mockFn();

  // Here I should only need to provide doWork1
  secondAction({ service1: { doWork1 }} )
}

interface Context {
  service1: {
    doWork1(): void
    doWork2(): void
  };
  service2: {
    doWork3(): void
  };
}

In my case, allowing this on the defineAction would be great. Something like:

function defineAction(fn: (context: from Context) => any) {
  return fn;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

6 participants