Fundamental concepts to know as a Typescript beginner - Part 2
These are the concepts I learned while doing the frontend masters' typescript course which helped me understand Typescript better.
Are you a beginner in TypeScript and looking to expand your knowledge?
Look no further!
In this article, we will be discussing the fundamental concepts of TypeScript that every beginner should know. From call signatures to non-null assertion operators, we will cover it all. By the end of this article, you will have a better understanding of TypeScript and be ready to take on more advanced topics.
So, let's dive in and expand our knowledge of TypeScript!
I assume that you have ready gone through the first part or know the basic concepts of typescript.
1. Call Signatures
We already know how to write type annotations for arguments and return types for a function. But how do we write callable types in interfaces and type aliases? By callable types, I mean callable functions that can be invoked. Up until now, we wrote functions like this with argument and return types.
const mutiple = (num1: number, num2: number): number => num1 * num2;
It is always necessary to annotate types in regular function declarations. We must include argument and return types because there's no way to infer them. If you compare with the below example, all the type information you need is on the left side of the assignment operator. This is called Call Signatures. It becomes much cleaner and more readable, especially when it comes to callbacks. We can define callable types both in interfaces and type aliases. As a result, we do not require type annotations for function arguments if we are using calling signatures.
type Multiple = (num1: number, num2: number) => number; // with types aliases
interface Multiple {
( num1: number, num2: number ): number;
} // with interfaces
const mutiple1: Multiple = (num1, num2) => num1 * num2;
2. Difference between void
and undefined
function return type
In Javascript, the function with no return statement returns a undefined
value. TypeScript has a way of describing it with a void
return type. In other words, the return type of this function should not be used; it should be ignored. Let's look at an example to understand better.
Here we are receiving an error since the push
function returns a number
. The difference between void
and undefined
return types is:
The return type of the
example1
function says my return type should be ignored, which means even if the callback function returns something. It will be ignored and left unused.In contrast,
example2
's return type says I will always return aundefined
value. The return type of all functions that return no value is inferred to bevoid
.
3. Construct Signatures
Contrary to call signatures, construct signatures describe what should happen with a new
keyword. In TypeScript, these are called Construct signatures because they are used to create a new object.
interface DateConstructor {
new (value: number): Date;
}
let MyDateConstructor: DateConstructor = Date;
const date1 = new MyDateConstructor();
Even though they are rare since we usually use classes that have all the functionalities built-in, you should be familiar with the construct signatures if you ever encounter them.
4. Function overloads
It is possible to overload functions in TypeScript. A function can have multiple parameters and return types with the same name. However, there should be a constant number of parameters.
It is necessary to define overload signatures and implementation signatures before we can put function overloading into practice.
There is nobody to the overload signature; it simply describes the parameters and returns of the function. In a function, multiple overload signatures may represent the different ways the function can be invoked.
On the other side, the implementation signature also contains the body that implements the function. We can have only one implementation signature.
The displayStudentDetails()
function has 2 overload signatures and one implementation signature.
In each overload signature, one way in which the function can be invoked is described. In the case of the displayStudentDetails()
function, you can call it in two ways: with a string argument or an array of strings argument.
Since the return type can be either string or an array of strings as per the first two function declarations, we must use compatible parameters and return type in the function definition.
5. Top types
Top types (symbol: ⊤) are types that describe anything i.e. any value allowable in JavaScript and that's what makes it a top type. It's the most flexible thing that exists in the type system. In TypeScript, there are two top types: any
and unknown
.
any
you can think of values with a any
type as working with normal JavaScript.
let coupon: any = "CNVD"
It’s important to understand that any is not necessarily a problem. Sometimes, it’s exactly the right type to use for particular situations. For example,
If we look at the type of console.log()
, it has a rest parameter. It takes any number of arguments you like, each of which is an any
. This is appropriate because console.log()
can log anything to the console. There's no reason to impose additional constraints here. It just indicates maximal flexibility.
unknown
like any, unknown
can accept any values:
However, unknown
is different from any
in a very important way. In order to use values with unknown types, you first need to apply a type guard. Let's look at an example to understand better:
It can be seen that when we declare value1
with any
type, there are no errors, even if we attempt to access a deep property. By contrast, value2
throws an error when accessing a deep property.
A value can be set to "unknown", but cannot be directly accessed. An unknown
value can't be used unless it's narrowed. Unless you use a type guard with it to check it out, to make sure that it's acceptable for use. It's almost as if it comes with a warning label saying that you must verify that it's what you think it is before doing anything with it.
As we can see below, once we type guard the value2
it infers itself as string
type.
6. Bottom Type
A bottom type (symbol: ⊥) is a type that describes no possible value allowed by the system. TypeScript provides one bottom type: never
. But, why do we actually need them? Bottom types are used with exhaustive conditioning.
Let's understand what exhaustive conditions are.
Exhaustive conditionals
An example of an exhaustive conditional is in the default
condition of the switch statement. In the below example, we have an exhaustive condition that handles every possibility of this course being one of a kind. The fact that we're saying this ends up as never that's another way of saying we've handled all of the cases.
Imagine a world where the stuff here, the kinds of courses, might be defined in some other file, maybe in a project with 10,000 JavaScript files. Someone alters this, and now all of the conditions, all of the different places and the rest of the code base, where they're looking through, they want to make sure they handle every case that this thing could be.
Now we see this conditional is no longer exhaustive. There is a new finance course which is launched. Therefore, we will get a meaningful error message if an unexpected value "slips through" until we run the code. Now you see all the friendly errors lit up, so you know that you need to go in and handle them all.
7. User-defined type guards
Real-world projects may require you to declare custom type guards to determine the types with custom logic. For example,
interface Course {
name: string;
type: string;
seats: number;
}
let businessCourses: unknown;
if (
businessCourses &&
typeof businessCourses === "object" &&
"name" in businessCourses &&
typeof businessCourses["name"] === "string" &&
"type" in businessCourses &&
typeof businessCourses["type"] === "string" &&
"seats" in businessCourses &&
typeof businessCourses["seats"] === "number"
) {
console.log(businessCourses); // type -> object
}
To determine whether the businessCourse
is equivalent to the course type, we can use the if condition. But as you can see, over time, it will become messy.
So, the next step is to refactor it into a helper function, making it easier to call that in multiple files & making it less messy.
function isCourseType(valueToCheck: any) {
return (
valueToCheck &&
typeof valueToCheck === "object" &&
"name" in valueToCheck &&
typeof valueToCheck["name"] === "string" &&
"type" in valueToCheck &&
typeof valueToCheck["type"] === "string" &&
"seats" in valueToCheck &&
typeof valueToCheck["seats"] === "number"
);
}
if (isCourseType(businessCourse)) {
businessCourse;
}
Even though we are using a type guard, the businessCourse
infers to unknown
🙁. The problem here is that even though these are all type guards, there's nothing about isCourseType
that says TypeScript to regard the true or false value that this function returns as an indication of the type of argument. TypeScript seems to have no idea that the return value isCourseType
has anything to do with the type of valueToCheck
.
The solution to this problem is user-defined type guards.
using is
type guard
So we can say effectively that "Yes, isCourseTypeDefined
returns a boolean
." but that boolean should be taken as an indication of whether the value to test confirms the type Course
. Now, if you see below, the type of businessCourse
the if the condition is Course
. It's only because we're telling typescript to trust us.
Note: The is
type guard can be our best friend or it can be our worst enemy. Let's look at an example & understand better:
Here, even if we are not checking in the function whether the valueToCheck
is of that particular type, we are still getting the someValue
with no errors, and we can access the name
, seats
& type
on it.
Type is only as good as your alignment between the actual checking logic you implement and your claim. If the logic contains any issues, we are going to run into big problems because typescript will do exactly what you say.
Right now, I'm saying everything is Course
type. So, 89 apparently has a property called name
on it. Typescript is doing what I'm telling it to do. So, these guards are the glue between compile-time validation and runtime behavior; we have to ensure that our compile-time validation and runtime behavior match up. And if they don't, we're lying to ourselves.
8. Non-null assertion operator
Using the non-null assertion operator (!.), we can eliminate the possibility of an undefined or null value. Despite the fact that this operator tells TypeScript to ignore null values and undefined values, the value could still be null or undefined.
Observe that line no. 11 throws a typescript error stating that the fruits
object it food
is undefined
. This is true in this case. Using the non-null assertion operator on line no. 13, we say, "Typescript, I know that fruits
will always be present in food
object" which will eventually cause runtime errors.
A non-null assertion operator allows us to avoid unnecessary null and undefined checks. You shouldn't use this unless you are sure the variable or expression cannot be null. It is not recommended to use this in your app or library code.
That's all for today! Thank you very much for your patience.
These concepts are essential for those who want to expand their knowledge of TypeScript and be ready to take on more advanced topics. It is important to note that while some of these concepts can be powerful tools, they should be used with care and caution to ensure the code remains reliable and maintainable.
Let me know one new concept you learned from this blog that you weren't aware of.
I would love to know your answer and feedback :)