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

Branding number type with string enum simplifies to never #32891

Closed
webstrand opened this issue Aug 14, 2019 · 11 comments
Closed

Branding number type with string enum simplifies to never #32891

webstrand opened this issue Aug 14, 2019 · 11 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@webstrand
Copy link
Contributor

TypeScript Version: 3.6.0-dev.20190814

Search Terms: brand, numeric brand, branding

Code

const enum ϕ { _ = "" }
type Slot = number & ϕ; // never

let x: Slot = 5; // no error

Expected behavior:
Slot should be an alias of the intersection type number & ϕ and assignment to x should fail with:

Type '5' is not assignable to type 'ϕ'.

This is the behavior in 3.5.1.

Actual behavior:
Slot is an alias of never and assignment to x succeeds without error.

This is the behavior in 3.6.0.

Playground Link: playground shows the expected behavior because the issue is only present on the development branch.

Related Issues:

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Aug 14, 2019
@RyanCavanaugh
Copy link
Member

Intersections of string and number primitives should reduce to never; this is the intended behavior since such a value cannot exist.

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Aug 14, 2019

assignment to x succeeds without error.

Am I reading this right?
I thought you couldn't assign non-never to never?

If you're saying this works,

const x : never = 5;

Then it's a bug, right?

@webstrand
Copy link
Contributor Author

You're right, somehow I missed that. Assignment to x does cause an error in 3.6.0

@webstrand
Copy link
Contributor Author

webstrand commented Aug 14, 2019

Are you sure this behavior is expected? String enums are not generally treated as strings. Unlike numeric enums, string enums will not silently cast:

The following behavior is true under 3.5.1

const enum ϕ { _ = "" }
const enum ψ { _ = "" }
type Foo = number & ϕ;
type Bar = number & ψ;

declare const foo: Foo;
declare const bar: Bar;

let t: Foo;
t = foo;      // ✓
t = bar;      // ✘ No accidental casting between different branded numbers
t = 5;        // ✘ No unintentional branding of strange numbers
t = 5 as Foo; // ✓

If I change ϕ and ψ to numeric const enums so that they do not reduce to never:
const enum ϕ { _ = 0 }, it becomes possible to silently brand bare numbers.

t = 5;        // ✓

I understand that this is the historic behavior of numeric enums, so I've been opting to brand with string enums instead.

I can't switch to void enums: const enum ϕ { } since that permits silently casting between different brands as well as silently branding bare numbers.

t = bar;      // ✓
t = 5;        // ✓

@webstrand
Copy link
Contributor Author

Under 3.6.0, the first example from above with string enums produces:

t = foo;      // ✓
t = bar;      // ✓
t = 5;        // ✘ Cant cast 5 to never
t = 5 as Foo; // ✓

@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@aslatter
Copy link

Is there a recommendation for type-branding that folks should use? We had previously tried using empty-const-enums as brands in prior versions of TypeScript, and this no longer works for string and boolean base-types as of TypeScript 3.6 due to the intersection reducing to never.

@webstrand
Copy link
Contributor Author

If you're branding object or string, you can keep on using string const enums. For numeric types, you will have to use object brands: type Foo = number & { __foo_brand: any }.

If you're okay with temporary brands, you can use generics to generate unique nominal types:

// Either T or U must be an unresolved generic to create 
// a temporary nominal type.
type Nom<T, U> = T extends U ? unknown : unknown;

class someClass<T = unknown> {
    x!: string & Nom<T, "foo">;
    y!: string & Nom<T, "bar">;

    constructor() {
        // Branded inside the class
        this.x = this.y; // ✘ string is not assignable to Nom<T, "foo">
    }

}

// Not branded outside
declare let inst: someClass;
inst.x = inst.y; // ✓ (x: string, y: string)

These brands reduce to unknown, so they disappear once the class is instantiated.

@coorasse
Copy link

coorasse commented Sep 3, 2019

I have faced a similar issue while upgrading to Typescript 3.6.2.
The following code:

enum DateStringBrand { }
export type DateString = DateStringBrand & string;

function checkValidDateString(str: string): str is DateString {
    return str.match(/^\d{4}-\d{2}-\d{2}$/) !== null;
}

export function toDateString(date: Date | moment.Moment | string): DateString {
    const dateString = moment(date).format('YYYY-MM-DD');
    if (checkValidDateString(dateString)) {
        return dateString;
    }
}

which was working fine in 3.5.2, now is raising the following error:

[tsl] ERROR in file.tsx(10,68)
      TS2534: A function returning 'never' cannot have a reachable endpoint.

this is because now the type DateString evaluates to never.

Is this somehow connected to #33164?

@webstrand
Copy link
Contributor Author

Doesn't look like it. Your issue is that DateStringBrand & string is equivalent to number & string which reduces to never. You can fix by using enum DateStringBrand { _ = "" } instead.

UppaJung added a commit to UppaJung/typescript-book that referenced this issue Sep 16, 2019
Addresses: basarat#521
Uses technique from: microsoft/TypeScript#32891

Also note: This "& string" that remains in this code sample appears to do nothing as of TypeScript 3.6.2, but presumably should be kept for backwards compat.
@benjaffe
Copy link

benjaffe commented Dec 7, 2020

I've noticed that this can cause problems if we're doing the lazy nil check of if (!!value), since '' is falsey. So since we only care about the stringiness of the unused string, not the value, we can set the value to something that won't get in the way of lazy nil checking. Here's the snippet from my codebase:

// UID brands... this allows UIDs to be typed individually based on
// their usage, instead of everything being `string`. These first
// enums are the brands, and aren't used except to make the UID types.
// The string defined inside is not used for anything. We don't set it to
// to `''` because that means we can't check nil'ness by doing `if (!value)`,
// since that will collapse the brand type into never. The only thing we
// care about with this unused string is its stringiness, and if it's set
// to the empty string, the actual value can get in the way of our simple
// nill checks.

enum ElementUIDBrand { _ = '¯\_(ツ)_/¯' } // prettier-ignore
export type ElementUID = ElementUIDBrand & string;

Hat tip to Samuel Sonne on stack overflow for their answer which nudged me in this direction, and whose humorous string choice I copied. https://stackoverflow.com/questions/52897022/why-cant-brand-enums-be-unioned-in-typescript-3

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

7 participants