I like to think of typescript as a mean to do calculations on types. Just as we use language features or composition of any sort to do calculation on data, we do calculations on types.
Javascript is a weakly typed and a dynamic language. That means it does type checking after running (dynamic) and also not very strict with type checking when it does that (weak).
Typescript came to the rescue as a potential candidate to solve these problems. Typescript is more strict (not as weak) and does things at compile time (static).
Typescript is a fundamentally unsound and structural type system.
What does that mean? That means that it cannot guarantee that the types at compile time will be the same type at runtime (just think about the any type). Why we want such a type system?
A very good question. That is the main reason I was more than reluctant to get into Typescript. I mean have a look at the languages of OCaml / ReasonML or Rust. They are the Italy of programming languages. Elegance above all.
Their type system is a strict father, but a fair one. Whereas typescript, well, not so much. Why did I sort of changed my mind?
I still adore ReasonML. I like Rust more and more. But Typescript just nailed something. I think that is incremental adaptability. Because it is unsound because it is structural you can go from js to ts one chunk at a time.
If you know the library immer.js, I think there is a similarity between immer.js and TS. Immer.js is great because achieves immutability without introducing unnecessary API. It is easily digestible to every one of us. TS is something along that line. Easily digestible by JS developers or C#/Java peeps.
It is not perfect but good enough. I think that is a great one-liner for life in general.
Never strike for perfection neither on the individual nor on the collective level. Just do good enough work.
So TS is unsound, but still we can improve our codebase. We can achieve things we can do with stricter type system with a little workaround.
In javascript, we have the notion of structural versus reference equality.
copyjs
const a = {x: 3,y: 5,};const b = a;Object.is(a, { x: 3, y: 5 }); // falseObject.is(a, b); // true
The first comparison is false despite the two objects being structurally equal. Typescript does not work like this. In Typescript if something is structurally equal, they are equal. Most of the time.
copyts
type Person = {fistName: string;lastName: string;}function changeName(person: Person, name: string): Person {}const joe = { fistName: "Joe", lastName: "Doe" }; // its type is not Person, but { fistName: string, lastName: string }changeName(joe); // we can pass joe here, since joe's type structurally equal to Person
After talking about some basic characteristics of TS let's dive into how to recreate some cool things. One of the best feature of ReasonML for me was pattern matching, so when I started out Typescript I looked for something like that. TS does not allow that kind of sophistication out of the box, but we can achieve something very similar with the help of some TS abilities.
This pattern is widely used in reducers. Let's see what it is with purely JS. But as we see, it can be applied to other domains.
copyjs
function reducer(prevState, action) {switch (action.type) {case "SET_ERROR":return { ...prevState, error: action.payload.error };case "SET_VALUE":return { ...prevState, value: action.payload.value };default:return prevState;}}
This is a classic reducer implementation. We have a state and two different kind of actions. Both do different things. That's why they differ structurally as well. One has value in their payload one has error. How we can make them typed?
We can utilise typescript's control flow analysis, literal types and union types, discriminative unions to be more precise. With those we can achieve a sort of exhaustive pattern matching.
Typescript analysis our code and and tries to figure out what is the strictest type it can assign in each branch.
copyts
type NumberOrString = number | string;function fn(arg: NumberOrString) {if (typeof arg === "number") {} else {// here arg must be string}}
In this example TS automatically narrows down from the union number | string to string in the else branch and we have full autocompletion magic.
In TS we have primitive types, custom structures with interfaces or types. We can do more with TS. We can also use primitive values as types. Like strings, numbers, booleans
copyts
type One = 1;type Red = "red";type True = true;
That is powerful addition to the type system. Why would it be? Because we can narrow down types with the help of control flow analysis. Let's see a classic example.
copyts
type Circle = {kind: "circle";radius: number;};type Rectangle = {kind: "rectangle";x: number;y: number;};type Shape = Rectangle | Circle;function fn(arg: Shape) {if (arg.kind === "circle") {// here arg is Circle type}if (arg.kind === "rectangle") {// here arg is Rectangle type}}
Shape is the union type of Rectangle and Circle. Rectangle and Circle are normal types except their kind property is a literal type. It is not string, but either of type circle or rectangle. These literal types make our union type Shape a discriminative union. Based on the kind field we can safely tell if our shape is a rectangle or circle.
We can move on to our reducer example. Reducers folds to some type S by processing through of type I.
After discussing all the features we use for the pattern matching let's go back to our original example. The reducer function. Let's make it typed.
copyts
type ReducerFunction<S, I> = (sum: S, item: I) => S;// or to be specific in redux termstype ReducerFunction<State, Action> = (prevState: State, action: Action) => State;
The reducer is a lot similar to our rectangle example. We would like to describe our state transitions with actions, where one action rules out (discriminates) others. That sort of can be described like this.
copyts
type SetError = {type: "SET_ERROR",payload: {error: string}}type SetValue<T> = {type: "SET_VALUE",payload: {value: T}}type Action<T> = SetValue<T> | SetError;
Again, the type variable is not string but a literal type. A concrete value. SetValue is also generic but that does not have to do anything with literal types. We just need the type of value to be dynamic. We also need to define the structure of our state.
copyts
type State<T> = {value: T,error?: string}
After this we are all set to implement our reducer in a type safe manner.
copyts
function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {switch (action.type) {case "SET_ERROR":return { ...prevState, error: action.payload.error };case "SET_VALUE":return { ...prevState, value: action.payload.value };default:return prevState;}}
Our reducer is now type-safe thanks to the literal types. In the appropriate block we have the proper structure for our payload object. Thanks to unions, control flow analysis and literal types we have a good pattern matching experience.
But there is more. We can make it exhaustive. We can make the compiler moan when we forget to match against a member on the union type.
copyts
function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {switch (action.type) {case "SET_ERROR":return { ...prevState, error: action.payload.error };case "SET_VALUE":return { ...prevState, value: action.payload.value };default:const _shouldNotGetHere: never = actionreturn prevState;}}
The never type intends to implicate types and situations that should never happen. Unreachable code for example. So by logic, if we match against every case of Action, then the action type should become never.
copyts
type Action<T> = SetValue<T> | SetError | { type: "newAction" };function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {switch (action.type) {case "SET_ERROR":return { ...prevState, error: action.payload.error };case "SET_VALUE":return { ...prevState, value: action.payload.value };default:const _shouldNotGetHere: never = action // Type '{ type: "newAction"; }' is not assignable to type 'never'.return prevState;}}
So if the action type is never, we can safely assign it to another variable with the type never. But if we miss something it will complain that the missing type, in this case { type: "newAction" } is not the same as never.
We have a nice compile time warning that we missed one of the cases. Only by removing the _shouldNotGetHere variable or adding the appropriate case to our switch statement we can get rid of that error message.
We have seen the similarities between data and type manipulation. We have seen pattern matching on types, literal values being utilized, flow control and more. We cannot make our code 100% sound, but TS does a decent job at providing us with type safety, making our code more robust.