One day I came across this tweet from Lari Mazza:

As a software engineer who learned Python, Ruby, Javascript, and Clojure first, when I tried C++ it was a horror movie. I couldn’t do much, and it was so counterproductive and frustrating. Maybe because I was doing everything wrong and I didn’t understand types the right way.

But even though I had so many problems, I could implement a bunch of algorithms and data structures.

Now that I’m using more and more Typescript in my day-to-day job and my side projects, I feel I’m more prepared to confront types. Actually, not confront, but use them in my favor.

This post is my attempt to help developers think more in types and understand this mental model.

Thinking in JavaScript types

If you’re here, you’ve probably heard that Typescript is a superset of Javascript. If not, great, you just learned something new today. YAY!

Typescript is a superset because any Javascript code is valid in Typescript, syntactically speaking. It may or may not compile depending on the Typescript compiler configuration. But in terms of syntax, it works just fine.

This is why you can migrate Javascript to Typescript progressively by just replacing the .js extension with the .ts. Everything will be without type declarations (the any type), but that’s another story.

Also, if you code in Javascript - or any other programming language - you probably think in types:

  • “Hm, it is a list of integers, so I’ll need to filter only the even numbers and return a new list”
  • “This is an object, but I just need to get this string value from the property X”
  • “This function receives two parameters. Both A and B are integers and I want to sum them”

Yeah, you get the idea. We think in types. But they are just in our heads. We constantly think about them because we need to know how to handle, parse, or modify data. We need to know which methods we are allowed to use in this object type.

To give a more concrete example, imagine you want to sum the price of all products. A product object looks like this:

const product = {
  title: 'Some product',
  price: 100.00,
};

But now with a list of products:

const products = [
  {
    title: 'Product 1',
    price: 100.00,
  },
  {
    title: 'Product 2',
    price: 25.00,
  },
  {
    title: 'Product 3',
    price: 300.00,
  }
];

Ok! Now we want a function to sum all the products prices.

function sumAllPrices(products) {
  return products.reduce((sum, product) => sum + product.price, 0);
};

sumAllPrices(products); // 425

Just receive the products as the argument and reduce all product prices. Javascript works just fine. But while building this function you start to think about the data and how to handle it properly.

The first part: products as an argument. Here you just think: “well, we’re receiving a list of some objects”. Yeah, in our heads the products are a list. This is why we can think of using the reduce method. It is a method from the Array prototype.

Then we can think about the object in detail. We know that the product object has a price property. And this property is a number. This is why we can do product.price and sum with the accumulator.

Recapping:

  • products is a list of objects.
  • As a list, we can use the reduce method, as this method is a member of the Array prototype.
  • The produce object has some properties. One of them is the price, which is a number.
  • As a number property, we can use it to sum with the reduce accumulator.
  • We wanted to return a number, the sum of all products prices.

We are always thinking of data types, we just need to add the type annotations to make it more explicit and ask the compiler for help. Our memory is limited and the compilers are here to help us, humans.

The type system will not only make our data more consistent, but it can also provide autocompletion for data types. It knows the types, so it can show the members for the data. We will take a look at this idea later. Here I just wanted to show that we think in types in our heads.

Simples Types & Simple Uses

So we are ready to use some strongly typed programming languages like Typescript. We simply need to explicitly add type annotations to our data structures. It’s simple, right?

But sometimes it’s not that easy (usually it’s not easy when you come from dynamically typed languages. You feel unproductive. It feels like a battle against types). The idea here is to make this learning curve smoother and more fun.

Here we will see many examples of how to use types in Typescript. We’ll start with easy and silly examples and progressively make it more complex while designing the mental model to think in types.

As in Javascript, Typescript also has basic data types like number, string, boolean, null, etc. You can find all the basic data types in the Typescript Docs.

With these units of data, we can make our programs more useful. To be more practical, let’s get a simple example. A sum function.

How does it work in Javascript?

function sum(a, b) {
  return a + b;
}

Everything ok? Good.

Now let’s use it:

sum(1, 2); // 3
sum(2, 2); // 4
sum(0, 'string'); // '0string'   WTF!

The first two calls are what we expect to happen in our system. But Javascript is very flexible, it lets us provide any value to this function.

The last call is bizarre. We can call with a string, but it will return an unexpected result. It doesn’t break in development, but it will result in strange behavior in runtime.

What do we want? We want to add some constraints to the function. It will only be able to receive numbers. That way, we narrow the possibility of having unexpected behaviors. And the function return type is also a number.

function sum(a: number, b: number): number {
  return a + b;
}

Great! It was very simple. Let’s call again.

sum(1, 2); // 3
sum(2, 2); // 4
sum(0, 'string'); // Argument of type '"string"' is not assignable to parameter of type 'number'.

As we type annotate our function, we provide information to the compiler to see if everything is correct. It will follow the constraints we added to the function.

So the first two calls are the same as in Javascript. It will return the correct calculation. But in the last one we have an error in compile time. This is important. The error now happens in compile time and prevents us from shipping incorrect code to production. It says that the string type is not part of the set of values in the number type universe.

For basic types, we just need to add a colon followed by the type definition.

const isTypescript: boolean = true;
const age: number = 24;
const username: string = 'tk';

Now let’s increase the challenge. Remember the product object code we wrote in Javascript? Let’s implement it again, but now with the Typescript mindset.

Just to remember what we are talking about:

const product = {
  title: 'Some product',
  price: 100.00,
};

This is the product value. It has a title as string and the price as number. For now, this is what we need to know.

The object type would be something like this:

{ title: string, price: number }

And we use this type to annotate our function:

const product: { title: string, price: number } = {
  title: 'Some product',
  price: 100.00,
};

With this type, the compiler will know how to handle inconsistent data:

const wrongProduct: { title: string, price: number } = {
  title: 100.00, // Type 'number' is not assignable to type 'string'.
  price: 'Some product', // Type 'string' is not assignable to type 'number'.
};

Here it breaks down into two different properties:

  • The title is a string and should not receive a number.
  • The price is a number and should not receive a string.

The compiler helps us to catch type errors like that.

We could improve this type annotation by using a concept called Type Aliases. It’s a way to create a new name for a specific type.

In our case, the product type could be:

type Product = {
  title: string;
  price: number;
};

const product: Product = {
  title: 'Some product',
  price: 100.00,
};

It’s better to visualize the type, add semantics, and maybe reuse in our system.

Now that we have this product type, we can use it to type the products list. The syntax looks like this: MyType[]. In our case, Product[].

const products: Product[] = [
  {
    title: 'Product 1',
    price: 100.00,
  },
  {
    title: 'Product 2',
    price: 25.00,
  },
  {
    title: 'Product 3',
    price: 300.00,
  }
];

Now the function sumAllPrices. It will receive the product and return a number, the sum of all product prices.

function sumAllPrices(products: Product[]): number {
  return products.reduce((sum, product) => sum + product.price, 0);
};

This is very interesting. As we typed the product, when we write product., it will show the possible properties we can use. In the product type case, it will show the properties price and title.

sumAllPrices(products); // 425
sumAllPrices([]); // 0
sumAllPrices([{ title: 'Test', willFail: true }]); // Type '{ title: string; willFail: true; }' is not assignable to type 'Product'.

Passing the products will result in the value 425. An empty list will result in the value 0. And if we pass an object with a different structure - Typescript has a structural type system and we will dig deep into this topic later - the compiler will throw a type error telling that the structure is not part of the Product type.

Structural Typing

Structural typing is a type of type compatibility. It’s a way to understand the compatibility between types based on its structure: features, members, properties. Some languages have type compatibility based on the names of the types, and it’s called nominal typing.

For example, in Java, even if different types have the same structure, it will throw a compile error because we are using a different type to instantiate and define a new instance.

class Person {
  String name;
}

class Client {
  String name;
}

Client c = new Person();  // compiler throws an error
Client c = new Client();  // OK!

In nominal type systems, the relevant part of a type is the name, not the structure.

Typescript, on another hand, verifies the structural compatibility to allow or not specific data. Its type system is based on structural typing.

The same code implementation that crashes in Java, would work in Typescript.

class Person {
  name: string;
}

class Client {
  name: string;
}

const c1: Client = new Person(); // OK!
const c2: Client = new Client(); // OK!

We want to use the Client type, and it has the property name, to point to the Person type. It also has the property type. So Typescript will understand that both types have the same shape.

But it is not only about classes, but it works for any other “object”.

const c3: Client = {
  name: 'TK'
};

This code compiles too because we have the same structure here. The typescript type system doesn’t care about if it is a class, or an object literal if it has the same members, it will be flexible and compile.

But now we will add a third type: the Customer.

class Customer {
  name: string;
  age: number;
};

It not only has the name property, but also the age. What would happen if we instantiate a Client instance in a constant of type Customer?

const c4: Customer = new Client();

The compiler will not accept that. We want to use the Customer, that has name and age. But we are instantiating the Client that has only the name property. So it doesn’t have the same shape. It will cause an error:

Property 'age' is missing in type 'Client' but required in type 'Customer'.

The other way around would work because we want Client, and Customer has all the properties (name) from Client.

const c5: Client = new Customer();

It works fine!

We can go on for enums, object literals, and any other type, but the idea here is to understand that the structure of the type is the relevant part.

Runtime and Compile time

This is a much more complex topic in programming language theory, but I wanted to give some examples to distinguish runtime from compile time.

Basically, the runtime is the execution time of a program. Imagine your backend receiving data from a frontend form page, handling this data, and saving it. Or when your frontend is requesting data from a server to render a list of Pokemons products.

Compile time is basically when the compiler is executing operations in the source code to satisfy the programming language’s requirements. It can include type checking as an operation, for example.

Compile time errors in Typescript, for example, are very related to the code that we wrote before:

  • When the type is missing property: Property 'age' is missing in type 'Client' but required in type 'Customer'.
  • When the type doesn’t match: Type '{ title: string; willFail: true; }' is not assignable to type 'Product'.

Let’s see some examples to have a better understanding.

I want to write a function to get the index of a part of the passed programming language.

function getIndexOf(language, part) {
  return language.indexOf(part);
}

It receives the language and the part that we will look for to get the index.

getIndexOf('Typescript', 'script'); // 4
getIndexOf(42, 'script'); // Uncaught TypeError: language.indexOf is not a function at getIndexOf

When passing a string, it works fine. But passing a number, we got a runtime error Uncaught TypeError. Because a number doesn’t have an indexOf function, so we can’t really use it.

But if we give type information to the compiler, in compile time, it will throw an error before running the code.

function getIndexOf(language: string, part: string): number {
  return language.indexOf(part);
}

Now our program knows that it will need to receive two strings and return a number. The compiler can use this information to throw errors when we get a type error… before runtime.

getIndexOf('Typescript', 'script'); // 4
getIndexOf(42, 'script'); // Argument of type '42' is not assignable to parameter of type 'string'.

Maybe, for small projects (or small functions like ours) we don’t really see too much benefit.

In this case, we know that we need to pass a string, so we won’t pass a number to the function. But when the codebase grows or you have many people adding code and more complexity, it’s clear to me that a type system can help us a lot to get errors in compile time before shipping code to production.

At first, we need all the learning curve to understand types and all the mental models, but after a while, you’ll be more used to type annotations and eventually become friends with the compiler. It would be a helper, not a yeller.

As we are learning about the basic difference between compile time and runtime, I think it’s great to differentiate types from values.

All the examples I’ll show here can be copied and run in the Typescript Playground to understand the compiler and the result of the compilation process (aka the “Javascript”).

In Typescript, we have two different universes: the value and the type spaces. The type space is where types are defined and used to enable the compiler to do all the great magic. And the value space is the values in our programs like variables, constants, functions, value literals, and things that we have in runtime.

It’s good to have an understanding of this concept because in Typescript we can’t use type checking in runtime. It has a very clear separation between type checking and the compilation process.

Typescript has the process of type checking the source code types and sees if everything is correct and consistent. And then it can compile to Javascript.

As these two parts are separate, we can’t use type checking in runtime. Only in “compile time”. If you try to use a type as a value, it will throw an error: only refers to a type, but is being used as a value here.

Let’s see examples of this idea.

Imagine we want to write a function called purchase where we receive a payment method and based on this method, we want to do some action. We have a credit card and a debit card. Let’s define them here:

type CreditCard = {
  number: number;
  cardholder: string;
  expirationDate: Date;
  secutiryCode: number;
};

type DebitCard = {
  number: number;
  cardholder: string;
  expirationDate: Date;
  secutiryCode: number;
};

type PaymentMethod = CreditCard | DebitCard;

These types are in the Type space, so it only works in compile time. After type checking this function, the compiler removes all the types.

If you add these types in the Typescript Playground, the output will be only a strict definition "use strict";.

The idea here is to really understand that the types live in the Type space and will not be available in the runtime. So in our function, it won’t be possible to do this:

const purchase = (paymentMethod: PaymentMethod) => {
  if (paymentMethod instanceof CreditCard) {
    // purchase with credit card
  } else {
    // purchase with debit card
  }
}

In the compiler it throws an error: 'CreditCard' only refers to a type, but is being used as a value here..

The compiler knows the difference between the two spaces and that the type CreditCard lives in the Type space.

The playground is a very cool tool to see the output of your Typescript code. If you create a new credit card object like this:

const creditCard: CreditCard = {
  number: 2093,
  cardholder: 'TK',
  expirationDate: new Date(),
  secutiryCode: 101
};

The compiler will type check it and do all the magic and then it transpiles the Typescript code to Javascript. And we have this:

const creditCard = {
    number: 2093,
    cardholder: 'TK',
    expirationDate: new Date(,
    secutiryCode: 101
};

The same object, but now only with the value and without the type.

#typescript #javascript #developer

TypeScript Types Explained – A Mental Model to Help You Think in Types
2.85 GEEK