I am using an empty enum as intersection with string to augment a nominal type (according to these instructions https://basarat.gitbooks.io/typescript/docs/tips/nominalTyping.html#using-enums). Otherwise it's working correctly, but I'm getting errors from nullable properties even though there is an if check to make sure it's not undefined or null.
TypeScript Version: 3.2.0-dev.20181023
Search Terms: "intersection nullable guard" "intersection undefined guard" "undefined nominal guard" "guard undefined"
Code
// Regular type alias (working correctly)
type UuidString = string
type DataWithString = {
id?: UuidString
};
function doSomethingWithString(id: UuidString) {
// ...
}
function handleDataWithString(data: DataWithString) {
if (data.id) { // if condition correctly acts as guard against undefined
doSomethingWithString(data.id) // No error here
}
}
// nominal type for UUID
// according to https://basarat.gitbooks.io/typescript/docs/tips/nominalTyping.html#using-enums
enum UuidType { }
type Uuid = UuidType & string
type DataWithUuid = {
id?: Uuid
}
function doSomethingWithUuid(id: Uuid) {
// ...
}
function handleUuid(data: DataWithUuid) {
if (data.id) { // if condition not working as guard against undefined
doSomethingWithUuid(data.id) // <-- Error
}
}
Expected behavior:
no errors
Actual behavior:
test.ts:33:29 - error TS2345: Argument of type 'undefined' is not assignable to parameter of type 'Uuid'.
Type 'undefined' is not assignable to type 'UuidType'.
33 doSomethingWithUuid(data.id) // <-- Error
~~~~~~~
Playground Link: (+ select strictNullChecks from options)
Related Issues:
enum by default is a number type. So UuidType & string is something that is both a number and a string, which can never happen. When you write id?: Uuid, that generates a property whose type is Uuid | undefined. When TypeScript sees a union type it will try to simplify it, such as by removing impossible members; Uuid is impossible so the union simplifies to undefined. The if test won't work because data.id doesn't have any non-undefined types to narrow to.
You may want to send your :+1: to #202.
I think another way to interpret the behaviour, according to this comment, is that the an enum type is the union type of its members. As the enum is empty its type is the empty union, which is the same as the never type. So never & string reduces to never. Either way the result is the same as described by Andy.
I would also add an issue on the TypeScript book because this method of writing nominal types seems flawed.
Can be made to work by switching the enum to a string enum to get the nominality:
enum UuidUniverse { UuidType = '' } // <-- Now a string enum
type Uuid = UuidUniverse.UuidType & string
type DataWithUuid = {
id?: Uuid
}
function doSomethingWithUuid(id: Uuid) {
// ...
}
function handleUuid(data: DataWithUuid) {
if (data.id) { // if condition now working as guard against undefined
doSomethingWithUuid(data.id) // <-- Works
}
}
Symbol-driven branding doesn't work (for the same reasons as the int-based enums don't work) as Andy has already pointed out. Literal-driven branding does work too:
interface Nominal<T/*: must be an enum or a literal */> {
'nominal structural brand': T
}
enum UuidType {}
type Uuid = string & Nominal<UuidType>;
// ... etc. ...
enum UuidUniverse { UuidType = '' } // <-- Now a string enum
type Uuid = UuidUniverse.UuidType & string
If you hover over it you'll see Uuid = UuidUniverse, because:
UuidUniverse.UuidType is already a string, so & string has no effectUuidUniverse has only one element and so is equivalent to that element.Thanks @svieira, just came here to post that exact same thing as I figured I'll try it based on andys comment on them being different type. But now reading again what @andy-ms says, it means
enum UuidUniverse { UuidType = '' } // <-- Now a string enum
type Uuid = UuidUniverse.UuidType & string
is equivalent to just
enum Uuid { UuidType = '' }
Not sure if there are any downsides to using just that, it seems to work correctly for my use case. Which is that we get data from another server as JSON and deserialize it, we have typings for the data and there are some id's that are UUIDs and then some other IDs that are string but not UUID. And we had few bugs where we passed the wrong type of IDs to functions that expected an UUID (as we previously just had type Uuid = string). We do not create those IDs in our codebase, they just flow through. So with this kind of typing we can catch those bugs since a value typed as Uuid can be used anywhere a string is expected but plain string cannot be used where Uuid is expected.
Another solution is to use the original code I posted and just add ! in the line that gives the error, e.g. doSomethingWithUuid(data.id!). Not that big a deal I guess. Or anyone have any other suggestions to handling this kind of situation?
Most helpful comment
enumby default is a number type. SoUuidType & stringis something that is both a number and a string, which can never happen. When you writeid?: Uuid, that generates a property whose type isUuid | undefined. When TypeScript sees a union type it will try to simplify it, such as by removing impossible members;Uuidis impossible so the union simplifies toundefined. Theiftest won't work becausedata.iddoesn't have any non-undefined types to narrow to.You may want to send your :+1: to #202.