Typescript: Recursive Mapped Type Narrows Branded Property to Never

Created on 4 Jan 2020  路  7Comments  路  Source: microsoft/TypeScript

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.

Working as Intended

Most helpful comment

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 馃槈

All 7 comments

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.

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

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

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

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.

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 馃槈

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

Was this page helpful?
0 / 5 - 0 ratings

Related issues

disshishkov picture disshishkov  路  224Comments

blakeembrey picture blakeembrey  路  171Comments

rbuckton picture rbuckton  路  139Comments

tenry92 picture tenry92  路  146Comments

yortus picture yortus  路  157Comments