typescriptfunctional-programmingnewtype

Newtype pattern in TypeScript with type guard and intersection types


I was reading this article about the newtype pattern in TS.

The author shows the difference between using intersection types and a fake box type. The latter is the one you foun mainly in all implementations (see for instance fp-ts and the lib newtype-ts), because of the lack of invariants described as the end of the section 'Intersection types':

More importantly, since TypeScript is structurally typed we can pass values of our newtype anywhere the base type is expected. This can be very nice in general, as we can use the type in all the utility functions we might have for original, but also could be quite undesired if we want to maintain certain invariants of the value:

type SortedArray<T> =
    T[] & { readonly __tag: unique symbol };

function sort<T>(array: T[]): SortedArray<T> {
    return [...array].sort() as SortedArray<T>;
}

const sorted = sort([3, 7, 1]);

// no error here, but should be:
const notSortedAnymore = sorted.concat(2);

I've implemented the example myself in TS 4.4 and I may be missing something since the type of notSortedAnymore is clearly not a SortedArray but a number[].

To me, it does not seem like a problem, since the type system is not misled by the use of the concat method. I have a SortedArray, I concatenate the number 2, it is not possible to infer that it is still a SortedArray, so it gives my a array of numbers.

Given the fact that in TS, the use of intersection types for such a pattern allows to have a easier (maybe even clearer) implementation (= you can avoid the use of the lift function to apply simple functions like concat), why the newtype pattern is generally implemented using a fake box type instead of the intersection types? and what am I missing here?

Thanks a lot

Best


Solution

  • I may be missing something since the type of notSortedAnymore is clearly not a SortedArray but a number[].

    It's not just a TS 4.4 thing, I've also tried older versions of TypeScript and the result is no different.

    I believe the method in the example was just badly (or mistakenly?) chosen. Instead, it should have been

    type SortedArray<T> = T[] & { readonly __tag: unique symbol };
    
    function sort<T>(array: T[]): SortedArray<T> {
        return [...array].sort() as SortedArray<T>;
    }
    
    const sorted = sort([3, 7, 1]);
    
    // no error here, but should be:
    sorted.push(2);
    

    Now sorted is still a SortedArray<number> but its elements are not sorted. The problem with structural typing is that it allows all methods defined on the original type to be called, no matter if they are valid for the newtype or not. slice would be fine, push, pop or reverse are not.

    And it's not limited to methods, one could also define functions like

    function shuffle<T extends number[]>(arr: T): T {
        arr[0] = 4; // chosen by fair dice roll :-)
        return arr;
    }
    

    and use it like

    const sorted = sort([3, 7, 1]);
    const shuffled = shuffle(sorted);
    

    where shuffled will be inferred to be a SortedArray<number>.