Search code examples
typescriptmapped-types

Why does a branded string/number type made DeepWriteable no longer extend string/number?


I have a DeepWriteable type and a branded number type PositiveNumber. PositiveNumber extends number, but DeepWriteable<PositiveNumber> does not. Same story for a branded string type. What's going on here?

type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
type PositiveNumber = number & { __brand: 'PositiveNumber' };
type NonEmptyString = string & { __brand: 'NonEmptyString' };

type test1 = PositiveNumber extends number ? true : false;
// TRUE
type test2 = DeepWriteable<PositiveNumber> extends number ? true : false;
// FALSE
type test3 = NonEmptyString extends string ? true : false;
// TRUE
type test4 = DeepWriteable<NonEmptyString> extends string ? true : false;
// FALSE

(BTW, I'm only asking about branded types because regular number and string don't seem to have this behavior)

type test5 = DeepWriteable<number> extends number ? true : false;
// TRUE
type test6 = DeepWriteable<string> extends string ? true : false;
// TRUE

I've resorted to just adding a check to DeepWriteable to not map over numbers and strings which works fine, but I'm curious why it's necessary.

Is it because the -readonly transformation makes this a non-homomorphic mapped type? I think I can maybe see why this happens for string, because length is readonly. Is there a similar readonly property on number?


Solution

  • Any mapped type (even homomorphic ones) on a branded primitive (a primitive type which has been intersected with an object-like type) will end up destroying the primitive type.

    Homomorphic mapped types on regular, non-branded, primitives were special-cased in microsoft/TypeScript#12447 to return the primitive without alteration.

    But for branded primitives you get the mapping over all the properties and apparent properties of the input type, which means DeepWriteable<PositiveNumber> will give you a result with all the Number interface properties as well as a __brand property.

    As mentioned in this comment on the related issue microsoft/TypeScript#35992:

    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 ... That said, I'm describing the current behavior, not outlining any promises of future behavior. T... We do use some branding internally but don't file bugs against ourselves when we encounter weirdness 😉

    So it looks like the recommendation is to avoid doing too much type manipulation on branded primitives, or at least to test such manipulations thoroughly and tweak them to deal with any unexpected wrinkles that arise when the types do not compose as you might hope.