Skip to content

2.9 and 3.0 reduce type of optional branded string member as undefined #25709

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
dcolthorp opened this issue Jul 17, 2018 · 3 comments
Closed
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@dcolthorp
Copy link

TypeScript Version: 3.0.0-dev.20180712, 3.0-rc, 2.9. Our original code worked fine in 2.8 with strictNullChecks enabled.

Search Terms: strictNullChecks, branding, enum, optional, intersection, undefined

Code

// strictNullChecks must be enabled to see this problem
enum Brand {}
type BrandedString = string & Brand;

export const fromString = (s:string) => s as BrandedString

interface ComplexType {
  optional?: BrandedString;
}
const example: ComplexType = {
  optional: fromString("foo")
};

Expected behavior: Example type checks

Actual behavior: Example does not type check TypeScript thinks optional is has type undefined.

Playground Link: Ensure strictNullChecks is enabled. Link here

Related Issues: #25179 looks similarish

@ahejlsberg
Copy link
Member

This is working as intended and is the same issue as #24846. The compiler now removes empty intersection when they occur in union types. An empty intersection type is a type that is known to have zero possible values. Examples include "a" & "b" and string & number (a value can't be both "a" and "b" at the same time, nor can it be both a string and a number). An empty intersection type is effectively the same as the never type.

Since an enum is a subtype of number, the string & Brand type in your example is a type that has no possible values and it therefore becomes never.

Generally the recommended way to create branded types is to intersect with an object type:

type BrandedString = string & { __brand__: void };

@ahejlsberg ahejlsberg added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jul 17, 2018
@dcolthorp
Copy link
Author

dcolthorp commented Jul 17, 2018

Aha! Well, reducing empty intersection types to never certainly makes a lot of sense. I love it – intersecting enums always seemed like hack. However, the way in which that manifests today is pretty confusing, because you only get feedback about the issue in certain circumstances, sometimes far removed from the definition of the original type. (In our case it was a Pick of a compound type with a branded field, 2 file hops away.)

It seems that if an empty intersection is effectively the same as never, it should effectively be the same as never everywhere. There are probably reasons I'm not appreciating for not reducing invalid intersections to never right away, but the property of having an intersection behave uniformly does seem really appealing.

If TypeScript had reported my intersection type to be never when I hovered over it while debugging the issue I would have had a very strong hint about the root cause, and switched to an object intersection brand. Actually, the brand never would have survived as an enum because we would have caught the problem right away.

With the current behavior, user-defined intersection types seem to work just fine until you happen to want to e.g. use them for an optional property. Hovering shows the type definition, non-unioned uses work as they used to, and only in some circumstances do they behave in a matter inconsistent with every other type I've written.

But assuming there's more to the story than meets the eye, it would have been nice to have had some sort of hint that there was an issue with my branded type. Either a warning or even just a note in the tooltip for the type would have helped clarify the situation for me.

Thanks for the quick reply and the excellent work!

@typescript-bot
Copy link
Collaborator

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

3 participants