Before delving into TypeScript's intricacies, it's essential to grasp the foundational concept of types. This post will focus on enhancing our understanding of how TypeScript interprets types.
Given an object of the following type, we know what properties lie on it.
type ExampleType = {
property1: string;
proptery2: number;
};
type ExampleTypeTwo = {
property3: string;
property4: number;
};
And we know that if we have variables of those different types, they aren't assignable to each other.
let a: ExampleType = {
/* * */
};
let b: ExampleTypeTwo = {
/* * */
};
a = b; // Error
However, what would happen if we have types with the same structure?
type First = {
name: string;
};
type Second = {
name: string;
};
let first: First = {
/* * */
};
let second: Second = {
/* * */
};
first = second; // ... is ok?!
Turns out, that TypeScript allows this. To understand why we need to look at how TypeScript evaluates and compares types.
Nominal and Structural Typing
There are two main categories of typing systems when it comes to programming languages U+002d nominal and structural. Nominal typing systems differentiate types based on their explicit name. This means that if a type has all the same properties within it, but is named differently, the two types are not equivalent. Take the following Swift example.
class Dog {
let name: string
};
class Cat {
let name: string
};
In this case, both Cat
and Dog
have only a name
property of type string
, but their names are different. Within a
nominal typing system, a variable of type Dog
could not be set to an instance of Cat
.
var a = Dog(name: 'fido')
var b = Cat(name: 'kitty')
a = b // Error
Many popular programming languages like Swift (shown above) and C++ use nominal typing systems.
On the other hand, structural typing systems determine type equivalence (and difference) based solely on the properties the type has.
type Dog = { name: string };
type Cat = { name: string };
let a: Dog = { name: "fido" };
let b: Cat = { name: "kitty" };
a = b; // All good!
As you have probably figured out by now, TypeScript uses structural typing. It's not alone though, many other languages like Haskell, OCaml, and Elm use structural typing as well. A good way to think of structural typing is through the application of the infamous duck test.
If it walks like a duck and it quacks like a duck, then it must be a duck
The tradeoffs of these two systems are flexibility and type-safety. With structural typing there is a lot more flexibility as to what is accepted as a type; however, this can cause some issues like accidental type equivalence (as we've seen above). Nominal type systems avoid type equivalence issues, but the extra rigidity can make them harder to work with.
TypeScript Types
A good mental model to have when thinking about TypeScript types is to imagine them as sets, where all the values in the sets share a similar structure (be it their entire shape or some sub-shape within it), the type declaration we write defines that shared structure. Let's take a look at a concrete example. Take the declaration below.
type WithName = {
name: string;
};
The WithName
declaration defines the bare minimum requirements for TypeScript to consider something to be of type WithName
. The
WithName
set includes all of the following and much more.
type NamedArrayLike = {
[index: number]: string;
name: string;
};
type Dog = {
name: string;
bark: () => void;
walk: () => void;
};
type DifferentName = {
name: string;
};
let withName: WithName = { name: "" };
const indexSignature: NamedArrayLike = { 0: "", 1: "", name: "" };
const dog: Dog = {
bark: () => {},
walk: () => {},
name: "fido",
};
const differentName: DifferentName = { name: "different" };
// TypeScript is fine with all of these
withName = indexSignature;
withName = dog;
withName = differentName;
Anything that has a name property of type string is in that set.
Trying to get the Best of Both Worlds
There are times when having some of the rigid type-safety of a nominally typed language would be helpful. Times when having a primitive type like string have special context in certain situations. Using TypeScript features there are some ways to provide some of the rigidity of nominal typing. Take an access token. A simple implementation could look something like this:
type AccessToken = string;
While we would get the semantic benefits of the type alias, any string would be passable to something typed as an AccessToken
function get(token: AccessToken, path: string) {
/* * */
}
This is an instance where we'd like string to have some nominal safety. To accomplish this, we can use two TypeScript features
- intersections and type assertions. The basic idea is to take some type and create an intersection with some unique object literal. Then, to assign a variable to that type we use type assertions.
type AccessToken = string & { readonly "": unique symbol };
function getAccessToken(token: string): AccessToken {
return token as AccessToken;
}
// `userAccessToken` is type `AccessToken`
let userAccessToken = getAccessToken("");
userAccessToken = "a"; // ERROR:
// Type 'string' is not assignable to type 'AccessToken'.
// Type 'string' is not assignable to type '{ readonly "": unique symbol; }'
{readonly "": unique symbol}
looks strange, so let's break it down real quick. We've defined a readonly
property
called
""
whose type is unique symbol
. The unique symbol
type is a subset of symbol
and can only be created
using Symbol
or Symbol.for
. Since symbols are unique, this creates a unique type and intersecting it with string
provides the functionality of string with the uniqueness of the symbol. Essentially adding a unique identifier to our type.
Now our AccessToken type has been successfully separated from string and can function as a unique type.
function get(token: AccessToken, path: string) {
/* * */
}
get("abcd", "/movies"); // ERROR:
// Argument of type 'string' is not assignable to parameter of type 'AccessToken'.
// Type 'string' is not assignable to type '{ readonly "": unique symbol; }'.
get(getAccessToken("abcd"), "/movies"); // works
This solution isn't perfect, but it does give us enough rigidity to accomplish our initial goals. There are many workarounds to this
problem. TypeScript provides a sandbox where instead of using { readonly "": unique symbol}
they use { __brand: "some unique string"}
and there is a Github thread over 300 comments long on different ways to provide this kind of functionality.
If you have the time it's worth the read to see all the unique workarounds, along with some gotchas that could arise.
Now that we've had the chance to update our mental model a bit, it's time to put it to work in the section where we look at how we can make generics even more powerful by constraining them!