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
I may be missing something since the type of
notSortedAnymore
is clearly not aSortedArray
but anumber[]
.
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>
.