TypeScript is a structural type system, so if you compare two types in TypeScript that have the same members, the TypeScript compiler will consider them equivalent.
As a result of structural type-checking, two types with the same structure will be interchangeable. Here's an example:
type PostId = string | number;
type UserId = string | number;
let userId: UserId = "abc123";
let postId: PostId = "xyz789";
userId = postId;
TypeScript is great and I love it, but it often leads to a false sense of security; the compiler considering types with the same structure equivalent leads to some hard-to-find bugs.
Here's a simple example of it:
let userId: UserId = "abc123";
let postId: PostId = "xyz789";
const fetchPostById = (postId: PostId): Post => {
};
fetchPostById(userId);
Casually let your back-end server figure out what to do with the given userId
where it should've received a postId
?
No, let me help you avoid digging into the codebase on a Saturday afternoon because some feature you shipped yesterday is broken:
It's just 5 lines of code and really easy to add to your projects, so
don't install a package for this, ThePrimeagen will hate you for it:
declare const __type: unique symbol;
export type Nominal<Identifier, Type> = Type & {
readonly [__type]: Identifier;
};
Import Nominal
and use it as such:
import { Nominal } from "./nominal";
type UserId = Nominal<"UserId", string>;
type PostId = Nominal<"PostId", string>;
let userId = "randomId" as UserId;
let postId = "randomId" as PostId;
userId = postId;
Finally, now the variables in your projects are compatible if and only if they have the exact same types.
Now your types aren't just structural and have a real identity other than the members contained in the type. The type itself makes the variables unique.
Due to this, now the variable holds some meaning not just in its value, but also in its type, and you can take advantage of the variable's type having some meaning throughout your code. For instance:
type SortedArray<T> = Nominal<"SortedArray", Array<T>>;
const sortArray = <T>(arr: Array<T>): SortedArray<T> => {
return arr.sort() as SortedArray<T>;
};
const binarySearch = <T>(
sorted: SortedArray<T>,
val: T
): number | undefined => {
};
const arr = [10, 68, 35, 26, 42, 5];
binarySearch(arr, 39);
const sortedArray = sortArray(arr);
binarySearch(sortedArray, 39);
The pre-requisite of the Binary Search algorithm is that the input array that it operates on should be sorted.
So when you use the Binary Search algorithm, you're going to want to make sure the input array you pass in is always sorted.
To do this, you will perhaps sort the input array within the binarySearch
function itself, or perhaps make sure to always sort the array before you pass it to the binarySearch
function?
With using types this way, you can eliminate so many redundancies in your code that perform such validation checks.
This helps you beyond just making your code type-safe; it makes your code more performant.
Now you know for sure whether an Array is sorted or not, and handle things accordingly.
Under the hood, we're taking advantages of JavaScript's Symbol
:
const s1 = Symbol();
const s2 = Symbol();
const s3 = Symbol("randomSymbol");
const s4 = Symbol("randomSymbol");
s1 === s2;
s3 === s4;
Symbol()
in JavaScript returns a primitive data type (symbol
) which is guaranteed to be a unique value. The Symbol
constructor also takes in a string value that serves as a description of that symbol -- note that it has nothing to do with the "identity" of a symbol other than that.
We use symbols as unique symbols
, as such:
declare const __type: unique symbol;
export type Nominal<Identifier, Type> = Type & {
readonly [__type]: Identifier;
};
According to the
TypeScript Docs; "Each reference to a
unique symbol
implies a completely unique identity that’s tied to a given declaration."
As evident from the TypeScript errors in the first example of Nominal types in practice:
As you can see, all we are really doing is intersecting the type string
with {readonly __type: "PostId"}
by using TypeScript's intersection type operator &
.
We can't assign the nominal types to variables directly, so we need to use as
everywhere;
const userId: UserId = "abc123";
But as a work-around to that, you can have functions to type-cast your variables into nominal types, as such:
function UserId(id: string): UserId {
return id as UserId;
}
function PostId(id: string): PostId {
return id as PostId;
}
let userId = UserId('id');
let postId = PostId('id');
And with just that, your code has type-safety 10x
better than TypeScript's type-safety. Cheers!