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

Idea for allowing destructuring non-common property of discriminated unions #46318

Open
5 tasks done
uhyo opened this issue Oct 12, 2021 · 9 comments
Open
5 tasks done
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

@uhyo
Copy link
Contributor

uhyo commented Oct 12, 2021

Suggestion

πŸ” Search Terms

type narrowing discriminated union destructing does not exist on type

βœ… Viability Checklist

My suggestion meets these guidelines:

  • 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 TypeScript's Design Goals.

⭐ Suggestion

Recent & future versions of TypeScript are strengthening support for discriminated unions. Looking at the most recent effort #46266 , it seems that we want to facilitate combination of destructuring assignment and discriminated unions. The ongoing work is wonderful, but it is one step behind being fully usable everywhere; in order to utilize that brand new feature, a common property (payload in that PR) must exist on every union constituents. Generally, this is not the case.

So this suggestion shows off idea on how we can extend the feature to discriminated unions of other shapes.

Suggestion: destructuring a property that might exist (that exists on only some of union constituents) is allowed, but access to such variable is not allowed until proper narrowing is done.

type Option<T> = {
  kind: 'some';
  value: T;
} | {
  kind: 'none';
};

// destructuring 'value' is allowed ↓
function unwrap<T>({ kind, value }: Option<T>): T {
  // use of 'value' is not allowed here because it may not exist.
  value;

  if (kind === 'some') {
    // use of 'value' is now allowed.
    return value;
  }
  throw new Error('unwrap: expected some');
}

Such a variable that causes an error on access is not something novel; let variables that aren't assigned yet already behave similarly.

πŸ“ƒ Motivating Example

As shown in above code sample, discriminated union constituents may not always have properties of the same name. Rather, given that discriminated unions tend to be used to express β€œor” logics, such a situation feels rare.

In addition, even if all constituents could have a property of the same name, giving them different names is often better.

type OkResult<T> = {
  type: "ok";
  value: T;
};
type ErrorResult = {
  type: "error";
  error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

In this example, we could rename both value and error to payload so that it can benefit from the ongoing PR, but the new name feels less descriptive.

Placeholder property definitions could be added so that all property names become common, but I don't really feel it is the right way to go:

type OkResult<T> = {
  type: "ok";
  value: T;
  error?: undefined;
};
type ErrorResult = {
  type: "error";
  value?: undefined;
  error: Error;
};

This works but it allows users to easily skip inspecting type, particularly thanks to the optional chaining operator. I have seen several bugs due to not properly handling the type, so in my most recent project I decided to remove these placeholder ?: undefined definitions from discriminated unions and force users to inspect type before accessing each constituent's contents.

Therefore, a solution that allows us to use non-common property names and also utilize destructuring assignments would be ideal.

πŸ’» Use Cases

Above two types (Option<T> and Result<T>) are the most basic use cases; there should be a lot more in the wild.

@andrewbranch andrewbranch added 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 labels Oct 12, 2021
@lazytype
Copy link

lazytype commented Nov 4, 2021

Just some questions to help flesh this out:

Should giving non-common properties default values be disallowed?

type Union = { kind: 'x' } | { kind: 'y' ; value: number }
function example({ kind, value = 42 }: Union) {
  //          is this allowed? ^^^^

  // if so would this be allowed now?
  value;

Should destructuring non-common properties be allowed?

type Union = { kind: 'x' } |  { kind: 'y' ; value: { a: number, b: number} }
function example({ kind, value: { a, b } }: Union) {
   // Use of 'a' and 'b' not allowed because they may not exist
   a;
   b;

@uhyo
Copy link
Contributor Author

uhyo commented Nov 4, 2021

Thanks for your interest πŸ™‚

Should giving non-common properties default values be disallowed?

Theoretically that syntax could be allowed, but that'd have little value. You would still need to perform an appropriate narrowing before accessing value, considering the following case:

type Union = { kind: 'x' } | { kind: 'y' ; value: number }
function example(_: Union) {}

const badObj = {
    kind: "x" as const,
    value: "Hello!"
};
example(badObj);

Should destructuring non-common properties be allowed?

I don't think this one can be allowed. A runtime error would be inevitable because destructuring occurs before narrowing is done.

@lazytype
Copy link

lazytype commented Nov 4, 2021

Theoretically that syntax could be allowed, but that'd have little value

They could still provide value in cases like the following:

type Union = { kind: 'x' } | { kind: 'y' ; value?: number }  // note how `value` is optional

function example({ kind, value = 42 }: Union) {
  if (kind === 'y') {
    value;  // it's `number` rather than `number | undefined`
  }
  if (kind == 'x') {
    value; // this is not allowed
  }
}

A runtime error would be inevitable because destructuring occurs before narrowing is done.

Actually destructuring properties that don't exist isn't a runtime error; they just get assigned undefined

@uhyo
Copy link
Contributor Author

uhyo commented Nov 4, 2021

They could still provide value in cases like the following:

Indeed πŸ˜ƒ

Actually destructuring properties that don't exist isn't a runtime error; they just get assigned undefined

Yeah that's exactly why this suggestion is possible. But your example is a nested one, meaning that it might try destructuring undefined into { a, b }, which should be prevented.

@benhason1
Copy link

benhason1 commented Apr 12, 2022

I would love this idea to be implemented, thx for your suggestion!

@alinnert
Copy link

alinnert commented Apr 12, 2022

Isn't this available since TS 4.6? Here are the release notes.

@benhason1
Copy link

benhason1 commented Apr 12, 2022

Isn't this available since TS 4.6? Here are the release notes.

No, what was added is that when you destructure common property (exist in all of the types in the union) from object it can be narrowed.
This issue is about non common property(doesn't exist on all types of the union), for example:

function foo(): {a: string} | {b: string} {
  return {a: ''}
}

const { a, b } = foo() // error TS2339: Property 'a' does not exist on type '{ a: string; } | { b: string; }'.

@alinnert
Copy link

alinnert commented Apr 12, 2022

Oh, I didn't realize that little detail. Thanks for pointing that out.

@Luk-z
Copy link

Luk-z commented Apr 26, 2022

This feature would be very very appreciated

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