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

Recursive Mapped Type Narrows Branded Property to Never #35992

Closed
abirmingham opened this issue Jan 4, 2020 · 7 comments
Closed

Recursive Mapped Type Narrows Branded Property to Never #35992

abirmingham opened this issue Jan 4, 2020 · 7 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@abirmingham
Copy link

abirmingham commented Jan 4, 2020

TypeScript Version: nightly, 3.7.2, 3.6.3, 3.5.1 (issue occurs in all versions except 3.5.1)

Search Terms:
recursive, mapped, brand, branded, nominal

Code

/**
 * A simplified version of [email protected]'s `PreloadedState<S>`. Simplified to
 * produce a minimal repro.
 */
export type PreloadedState<S> = {
    [K in keyof S]: S[K] extends object ? PreloadedState<S[K]> : S[K]
};

export type Branded<K, T> = T & {
    __BRAND__: K; // (or K & void)
};

type InitialState = PreloadedState<{
    someString: Branded<'Foo', string>;
}>; // evaluates to { someString: never }

Expected behavior:
InitialState is typed as { someString: Branded<'Foo', string> }.

Actual behavior:
InitialState is typed as { someString: never }.

Playground Link:
Here

Context:
This issue affects usage of [email protected] when calling Redux.createStore. Redux.createStore uses a recursive mapped type for the preloadedState. In my case, PreloadedState<S> is incompatible with S, because PreloadedState<S> contains never types and S does not.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jan 7, 2020
@RyanCavanaugh
Copy link
Member

The produced output is correct. Effectively you've written a type that produces a string & object, which is uninhabitable.

You could consider reversing the conditional:

export type PreloadedState<S> = {
    [K in keyof S]: S[K] extends (string | number | boolean) ? S[K] : PreloadedState<S[K]>;
};

export type Branded<K, T> = T & {
    __BRAND__: K;
};

type InitialState = PreloadedState<{
    someString: Branded<'Foo', string>;
}>;

but ultimately Branded produces contradictory types and you're going to encounter problems sooner or later.

@abirmingham
Copy link
Author

abirmingham commented Jan 7, 2020

@RyanCavanaugh thanks for the response! Reversing the conditional seems like a good solution, as well as updating consumers to accept PreloadedState<S> | S. It turns out that the redux folks went with the latter solution for 5.x.x.

I am curious to know a couple things...

  1. Am I implementing Branded optimally? Is there a better approach at present for hacking in nominal typing?
  2. In an ideal world, would Branded<'Foo', string> resolve to never immediately? It's a bit confusing to me that its type is non-never prior to its acquaintance with PreloadedState. I feel like this is either a) a tsc implementation detail, or b) something I'm missing about how PreloadedState is operating here. My feeling is that b is more likely. I suppose the two aren't mutually exclusive either... :)

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Jan 8, 2020

It won't resolve to never immediately because TS' own source code uses brands on primitives. It would break their own code

@abirmingham
Copy link
Author

@AnyhowStep I see. Is there a general rule about when resolution to never occurs? Is this behavior documented? The reason I ask is this: as a consumer of branded types, I'd like to understand where they will fail. Thanks!

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Jan 8, 2020

Ummmmm........

Not as far as I know.
For example, in 3.5.1, true & number is not immediately reduced to never.

But in 3.7.2, it is immediately reduced to never.


However, primitive & object should be safe from that for a long, long time.
A lot of people rely on branded types.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jan 8, 2020

Branding a primitive with an object type is an at-your-own-risk endeavor. Basically you can do this as long as the type never goes through a simplifying transform (i.e. never gets produced from a higher-order operation). That said, I'm describing the current behavior, not outlining any promises of future behavior. The "best" way to do this, if you feel you have to, is to just fully replace the primitive with an opaque object type; this is "guaranteed" to not encounter problems at the expensive of losing access to the underlying primitive's behavior.

We do use some branding internally but don't file bugs against ourselves when we encounter weirdness 😉

@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.

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

4 participants