TypeScript Con De Tuti
Introduction
Hello everyone! This is Gentleman at the keyboard, bringing you an in-depth analysis of TypeScript and how this language can revolutionize the way we work in development teams. This book is an expansion of a video I uploaded on YouTube, where we talked about the basics of TypeScript, the advantages, and how it can help in a team. We will dive not only into the video content but also expand with practical examples, code, and key reflections so that you, my dear developers, can take your code to the next level.
Why TypeScript?
What is TypeScript?
TypeScript is a "superset" of JavaScript, which means it has everything JavaScript offers but adds more functionalities that are especially useful in large projects or teams. As I mentioned in the video, if JavaScript is good, TypeScript is JavaScript on steroids.
// Basic TypeScript example
let message: string = 'Hello, TypeScript!';
console.log(message);
Advantages of Using TypeScript in Teams
- Type Safety: Reduces common JavaScript errors by allowing variable type specification.
- Maintainability: The code is easier to understand and maintain.
- Refactoring: Safe and easy to perform thanks to the type system.
Fundamental Concepts of TypeScript
Variables and Types
One of the pillars of TypeScript is its typing capability. This avoids many common errors in JavaScript.
// Typing example in TypeScript
let isActive: boolean = true;
let quantity: number = 123;
Interfaces and Classes
TypeScript allows defining interfaces and classes, which facilitates the implementation of advanced design patterns and code organization.
interface User {
name: string;
age: number;
}
class Employee implements User {
constructor(public name: string, public age: number) {}
}
TypeScript in Practice
Practical Case Analysis
Let's analyze the video segment where we discussed variable mutability and how TypeScript can help control it.
// Immutability example in TypeScript
let x: number = 10;
// x = "Type change"; // This will generate an error in TypeScript.
Creating Effective Methods
We also discussed how the lack of clarity in types can lead to errors in seemingly simple methods.
function sum(a: number, b: number): number {
return a + b;
}
Best Practices and Patterns
Working with Teams
- Clarity: Always use types.
- Documentation: Take advantage of TypeScript features to document the code.
- Code Review: Encourage code reviews focused on improving typing.
Tools and Extensions
We'll talk about tools that can be integrated with TypeScript to further improve workflow, such as linters, code formatters, and more.
What is Transpiling?
Transpiling, in the programming world, is the process of converting code written in one language (or version of a language) to another language (or version of that language). In our case, we often talk about converting TypeScript to JavaScript. Basically, transpiling is like translating.
Why Do We Need to Transpile?
TypeScript is amazing because it gives us superpowers: types, interfaces, and many aids to avoid errors. But browsers and Node.js don't understand TypeScript, they only speak JavaScript. So we need a translator, and that translator is the TypeScript compiler (tsc).
Practical Example
Let's see this in action with a simple example. Imagine we have a TypeScript file script.ts
with the following code:
// script.ts
let message: string = 'Hello, world';
console.log(message);
This file contains a message
variable of type string and a console.log
to display it. Now, for our browser to understand this code, we need to transpile it to JavaScript. We do this with the tsc
(TypeScript Compiler) command:
tsc script.ts
After running this command, we get a script.js
file with the following content:
// script.js
var message = 'Hello, world';
console.log(message);
As you can see, the TypeScript compiler has converted (or "transpiled") the TypeScript code to JavaScript.
A Little More Magic: Compiler Configuration
We can do much more with our TypeScript compiler configuration. For example, we can define how we want the transpiling process to behave through a tsconfig.json
file. Here's a basic example:
// tsconfig.json
{
"compilerOptions": {
"target": "es6", // Indicates which version of JavaScript we want to transpile to
"outDir": "./dist", // Folder where the transpiled files will be saved
"strict": true // Enables all strict checks
},
"include": ["src/**/*.ts"], // Files to include in the transpilation
"exclude": ["node_modules"] // Files or folders to exclude
}
Making Magic with tsc --watch
If you want to take your workflow to the next level, you can use the tsc --watch
command, which will watch for any changes in your TypeScript files and automatically transpile them to JavaScript. It's like having a personal assistant always ready to help.
tsc --watch
In Summary
Transpiling is an essential process that allows us to write in modern languages with better features and then convert that code to a language that browsers and Node.js can understand. It's like having a translator that converts our words into something everyone can understand.
So next time you hear "transpile," you know it's just a translation process, ensuring our TypeScript brilliance reaches any JavaScript environment intact and clear.
Understanding Typing in JavaScript and TypeScript
JavaScript: A Dynamically Typed Language
Although it may not seem like it at first glance, JavaScript does have a type system, but it is dynamic. This means that a variable's type can change during the program's execution, introducing flexibility but also a propensity for hard-to-track errors. This is where JavaScript shows both its flexibility and limitations, as this feature can lead to confusion in large projects or when working in teams.
The V8 JavaScript engine, used by most modern browsers like Chrome and Node.js, handles dynamic typing in a particular way. When a variable or method is defined, V8 assigns an initial type based on the assigned value. This information is stored in a memory buffer.
// Example of dynamic typing in JavaScript
let value = 'Hello';
// Initially a string
value = 100; // Now a number
In this example, value
changes from a string to a number. It's like your dog suddenly deciding to be a cat! While useful at times, this can cause problems.
When a variable's type changes, V8 has to restructure how this variable is stored in memory. This involves recalculating and reallocating memory for the new data shape, which can be costly in terms of performance.
let a = 1; // 'a' is a number
a = 'one'; // 'a' is now a string
The V8 engine detects the type change and adjusts memory and internal references to accommodate the new type. This process can slow down execution if it occurs frequently.
TypeScript: Stability and Security Through Static Typing
In contrast, TypeScript introduces a static typing system, which forces the definition of data types for variables and functions from the beginning. This helps avoid many common JavaScript errors by making the code more predictable and easier to debug. By using TypeScript, you can have much stricter control over how data is handled in applications, resulting in more robust and secure code.
let value: number = 100;
// value = "Hello"; // This will cause an error in TypeScript
And that's it! Now value
can't change types and you ensure it will always be a number. It's like having a dog that will always be a dog.
TypeScript: The Superhero of Development
First, let's imagine that TypeScript is a superhero. Its mission: to save us from JavaScript code errors and nightmares. But, like any good superhero, it has its limits and areas of operation. In this case, TypeScript only uses its superpowers during development.
What Does TypeScript Do?
As we said earlier, TypeScript is JavaScript on steroids. It allows you to add types to your variables and functions, which helps avoid common errors. But here's the trick: when your application runs in the browser or Node.js, all that TypeScript code has been transformed (or transpiled) into JavaScript. It's like our superhero takes off the suit and puts on a regular uniform.
Linters: The Battle Companions
Now, let's talk about linters. They are like the superhero's teammates. Linters, like ESLint, work hand-in-hand with TypeScript to keep your code clean and error-free. While TypeScript focuses on types and code structure, linters handle style rules and best practices.
Practical Example
Let's look at an example to make this clearer. Suppose we are working on a super cool application:
// TypeScript
function greet(name: string): string {
return `Hello, ${name}`;
}
const greeting = greet('Gentleman');
console.log(greeting);
Here we have a greet
function that takes a name
of type string and returns a greeting. TypeScript ensures that we always pass a string to this function. But, when the time comes, this is what runs in your browser:
// Transpiled JavaScript
function greet(name) {
return 'Hello, ' + name;
}
var greeting = greet('Gentleman');
console.log(greeting);
Voilá! All the TypeScript code has been transformed into JavaScript.
Integration with ESLint
Now, let's add our linter to the team. Imagine we have a rule that says we must always use single quotes. Here's the .eslintrc.json
file:
{
"rules": {
"quotes": ["error", "single"]
}
}
If someone rebels and uses double quotes instead of single quotes, ESLint will scold us and remind us to follow the rules:
// Incorrect code according to ESLint
function greet(name: string): string {
return 'Hello, ' + name; // ESLint will notify us of this error
}
With ESLint integrated, our code will stay clean and consistent, teaming up with TypeScript to ensure everything is in order.
In Summary
TypeScript is our superhero during development, helping us write safer and more predictable code. But once everything is ready for action, it transforms into JavaScript. And with linters as battle companions, we keep our code in line.
So, the next time someone tells you that TypeScript "only works during development," you'll know that while true, that's precisely where it makes the difference.
Practical Example in TypeScript: The Use of any
and the Importance of Typing
any
and the Importance of TypingLet's see why, although TypeScript gives us powerful tools, using them incorrectly can lead us to the same problems we might have in JavaScript. Get ready for a little mental challenge!
Step 1: Declare a variable with a specific type
Let's start with something simple. In TypeScript, we can specify a variable's type to ensure it always contains the correct type of value. Look at this example:
let number: number = 5;
Now, if we try to assign a different type of value, like a string
, TypeScript will show us an error. This is great because it prevents errors at compile time. Let's see:
number = 'this should fail'; // Error: Type 'string' is not assignable to type 'number'.
Step 2: Use of the any
type
The any
type in TypeScript allows us to assign any type of value to a variable, similar to what happens by default in JavaScript. Let's see:
let flexibleVariable: any = 5;
flexibleVariable = 'I can be a string too';
flexibleVariable = true; // And now a boolean!
With any
, there are no compile-time errors, regardless of the type of data we assign. This gives us a lot of flexibility, but also eliminates the type safety guarantees that TypeScript offers.
Provocative question
Now, here comes the key question: If we are using any
, do you think it's okay? If you think not... then you don't like JavaScript! It would be the same as using 'any' in all our variables.
Exactly! Most would say that it is not a good practice to use any
, as we lose all the benefits of static typing that TypeScript offers. It's like going back to JavaScript, where we can easily make type errors.
Using any
takes us back to JavaScript's flexibility (and dangers). While any
can be useful in situations where we need a temporary solution or when working with third-party libraries for which we don't have types, we should avoid it in our main code. In TypeScript, the goal is to leverage the type system to write safer and more maintainable code.
Primitive Types in TypeScript
TypeScript enriches the set of primitive types of JavaScript, providing more robust control and options for variable declaration. Here are the main primitive types:
-
Boolean: True or false value.
let isActive: boolean = true;
-
Number: Any number, including decimals, hexadecimal, binary, and octal.
let amount: number = 56; let hexadecimal: number = 0xf00d; let binary: number = 0b1010; let octal: number = 0o744;
-
String: Text strings.
let name: string = 'Gentleman';
-
Array: Arrays that can be typed.
let numberList: number[] = [1, 2, 3]; let stringList: Array<string> = ['one', 'two', 'three'];
-
Tuple: Allow expressing an array with a fixed number of elements and known types, but not necessarily the same type.
let tuple: [string, number] = ['hello', 10];
-
Enum: A way to give more friendly names to sets of numeric values.
enum Color { Red, Green, Blue, } let c: Color = Color.Green;
-
Any: For values that can change type over time, it is a way to tell TypeScript to handle the variable like pure JavaScript.
let notSure: any = 4; notSure = 'maybe a string instead'; notSure = false; // now it's a boolean
-
Void: Absence of any type, commonly used as a return type in functions that do not return anything.
function warnUser(): void { console.log('This is a warning!'); }
-
Null and Undefined: Subtypes of all other types.
let u: undefined = undefined; let n: null = null;
Type Inference in TypeScript
TypeScript is smart when it comes to inferring variable types based on available information, such as the initial value of variables. However, this inference can be tricky.
let message = 'Hello, world';
// `message` is automatically inferred as `string`
Traps of Inference in TypeScript: A Practical Example
Hello, community! Today we will delve into a fascinating and sometimes tricky topic of TypeScript: the traps of type inference, using a practical example that will show us how TypeScript handles type inference within complex control structures.
Problematic Example
Consider the following TypeScript code:
const arrayOfValues = [
{
number: 1,
label: 'label1',
},
{
number: 2,
},
];
const method = (param: typeof arrayOfValues) => {
const indexArray = [1, 2];
indexArray.forEach(index => {
if (param[index].label) {
// param[index].label => string | undefined
console.log(param[index].label); // param[index].label => string | undefined
}
});
};
In this example, we have an arrayOfValues
that contains objects with number
and label
properties. However, you will notice that the second object in the array does not have the label
property defined. This makes the type of label
inferred as string | undefined
.
Inference Problem
When we pass arrayOfValues
to the method
function and use forEach
to iterate over an array of indexes, we make a check in each iteration to see if label
is present. While inside the if
block, one might expect TypeScript to understand that label
is not undefined
due to the check, the reality is that TypeScript still considers param[index].label
to be string | undefined
both inside and outside the if
.
Why does this happen?
TypeScript does not carry a "state" of the type through the flow of the code in the same way it would in a simpler context. Although within the if
block we have already verified that label
exists, TypeScript does not have a "memory" of this check for future references in the same code block. This is especially true when iterating or using more complex structures like forEach
, for
, etc., where type checks do not "propagate" beyond the immediate scope in which they are performed.
Tip for Handling Inference in Iterative Blocks
To better handle these cases and ensure that the code is type-safe without relying on TypeScript's inference, you might consider the following practices:
-
Assign to Temporary Variables: Sometimes, assigning to a temporary variable within the block can help.
indexArray.forEach(index => { const label = param[index].label; if (label) { console.log(label); // TypeScript understands that label is string here } });
-
Type Refinement with Guard Types: Use guard types to refine the types within iterative or conditional blocks.
indexArray.forEach(index => { if (typeof param[index].label === 'string') { console.log(param[index].label); // Now it is safe to say it is a string } });
Classes, Interfaces, Enums, and Const: How to Use Them for Typing in TypeScript?
Each of these elements has its own magic to help us write cleaner, scalable, and secure code. Let's break down each and see how and when to use them. 🎩✨
1. Classes
Classes in TypeScript are not only a template for creating objects but can also be used as types.
class Automobile {
constructor(public brand: string, public model: string) {}
}
let myCar: Automobile = new Automobile('Toyota', 'Corolla');
In the example, Automobile
not only defines a class but also a type. When we say let myCar: Automobile
, we are using the class as a type, ensuring that myCar
meets the structure and behavior defined in the Automobile
class.
2. Interfaces
Interfaces are powerful in TypeScript due to their ability to define contracts for classes, objects, and functions without generating JavaScript at compile time. They are ideal for defining data shapes and are extensively used in object-oriented programming and integration with libraries.
interface Vehicle {
brand: string;
start(): void;
}
class Truck implements Vehicle {
constructor(public brand: string, public loadCapacity: number) {}
start() {
console.log('The truck is starting...');
}
}
Interfaces not only allow typing objects and classes but can also be extended and combined, which is excellent for keeping code organized and reusable.
3. Enums
Alright, folks! Today we're going to talk about something really cool in TypeScript: enums! These little guys let you define a set of named constants, making your code more readable and maintainable. We're going to look at two types: numeric enums and string enums. Hang on tight, this is gonna be good!
Numeric Enums
Numeric enums are like a staircase: each step goes up by one. If you don't tell them otherwise, they start from zero and keep incrementing by one. Check this out:
Example
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
console.log(Direction.Up); // Output: 0
console.log(Direction.Left); // Output: 2
But, what if you want to start from another number? No worries, check this out:
enum Direction {
Up = 1,
Down, // 2
Left, // 3
Right, // 4
}
console.log(Direction.Up); // Output: 1
console.log(Direction.Right); // Output: 4
String Enums
Now, if you feel like using strings, TypeScript has got your back too. With string enums, you can assign whatever value you want to each member. They're more verbose, but really clear.
Example
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
console.log(Direction.Up); // Output: "UP"
console.log(Direction.Left); // Output: "LEFT"
Combined Usage
Heads up, you can mix numbers and strings in an enum, but be careful, it's not highly recommended because it can get messy. If you come across such code, you might feel like pulling your hair out. But anyway, for the curious ones, here it is:
Example
enum Mixed {
No = 0,
Yes = 'YES',
}
console.log(Mixed.No); // Output: 0
console.log(Mixed.Yes); // Output: "YES"
4. Const Assertions
In TypeScript, const
not only defines a constant at runtime but can also be used to make type assertions. Using as const
, we can tell TypeScript to treat the type more specifically and literally.
let config = {
name: 'Application',
version: 1,
} as const;
// config.name = "Another App"; // Error, because name is a constant.
This is especially useful for defining objects with properties that will never change their values once assigned.
Type vs Interface in TypeScript: When and How to Use Them
Although both can be used to define types in TypeScript, they have their particularities and ideal use cases. Let's break down the differences and understand when it's better to use each one. 🚀
What is interface
?
An interface
in TypeScript is primarily used to describe the shape objects should have. It is a way to define contracts within your code as well as with external code.
interface User {
name: string;
age?: number;
}
function greet(user: User) {
console.log(`Hello, ${user.name}`);
}
Interfaces are ideal for object-oriented programming in TypeScript, where you can use them to ensure certain classes implement specific methods and properties.
Advantages of using interface
- Extensibility: Interfaces are extensible and can be extended by other interfaces. Using
extends
, an interface can inherit from another, which is great for keeping large codebases well-organized. - Declaration Merging: TypeScript allows
interface
declarations to be merged automatically. If you define the same interface in different places, TypeScript combines them into a single interface.
What is type
?
The type
alias can be used to create a custom type and can be assigned to any data type, not just objects. type
is more versatile than interfaces in certain aspects.
type Point = {
x: number;
y: number;
};
type D3Point = Point & { z: number };
Advantages of using type
- Union and Intersection Types: With
type
, you can easily use union and intersection types to combine existing types in complex and useful ways. - Primitive and Tuple Types:
type
can be used for aliasing primitive types, unions, intersections, and tuples.
When to use interface
or type
?
-
Use
interface
when:- You need to define a 'contract' for classes or the shape of objects.
- You want to take advantage of interfaces' extension and merging capabilities.
- You are creating a type definition library or API that will be used in other TypeScript projects.
-
Use
type
when:- You need to use unions or intersections.
- You want to use tuples and other types that cannot be expressed with an
interface
. - You prefer to work with more flexible types and do not need to extend or implement them from classes.
The Concept of Shape in TypeScript
Now let's talk about a fundamental concept in TypeScript that helps us manage the structure and type of our objects: the shape. This concept is crucial for understanding how TypeScript handles typing and how we can make the most of our code. So, let's dive in! 🚀
What is Shape?
The concept of shape in TypeScript refers to the structure an object must have to be considered of a certain type. Basically, when we define a type or an interface, we are defining the shape that any object of that type must follow.
interface User {
name: string;
age: number;
}
let user: User = {
name: 'John',
age: 25,
};
In this example, User
defines the shape that the user
object must have: it must have the properties name
and age
of types string
and number
, respectively.
Type Inference and Shape
TypeScript is very good at inferring types based on the values we provide. However, type inference also relies on the shape of objects.
let anotherUser = {
name: 'Anna',
age: 30,
};
Here, TypeScript will infer that anotherUser
has the shape { name: string; age: number; }
without us having to specify it explicitly.
Traps of Inference with Shape
Sometimes, relying on type inference can lead to tricky situations, especially when working with complex objects and arrays. Let's revisit an example of how this can be problematic:
const arrayOfValues = [
{
number: 1,
label: 'label1',
},
{
number: 2,
},
];
const method = (param: typeof arrayOfValues) => {
const indexArray = [1, 2];
indexArray.forEach(index => {
if (param[index].label) {
console.log(param[index].label);
}
});
};
In this previously seen case, param[index].label
remains string | undefined
both outside and inside the if
block, despite having checked its existence. Why does this happen? Because TypeScript cannot guarantee that the shape will remain constant throughout the iteration without storing the check in a variable.
Proper Handling of Shape
To handle these situations correctly, it is better to store the checks in a variable, which gives TypeScript a clearer hint about the shape:
const method = (param: typeof arrayOfValues) => {
const indexArray = [1, 2];
indexArray.forEach(index => {
const item = param[index];
if (item.label) {
console.log(item.label);
}
});
};
Now, TypeScript understands that item.label
inside the if
is a string
and not undefined
.
Understanding union and intersection in TypeScript
Let's explore two fundamental operators in TypeScript that allow us to handle types flexibly and powerfully: |
(union) and &
(intersection). These operators are key to defining complex types and handling different scenarios in our programs. Let's dive into them!
The | (Union) Operator
The |
operator in TypeScript is used to combine types so that a value can be one of those types. That is, if we have TypeA | TypeB
, we are saying that a variable can be of type TypeA
or of type TypeB
.
type Result = 'success' | 'error';
let state: Result;
state = 'success'; // valid
state = 'error'; // valid
state = 'other'; // invalid, TypeScript will raise an error
In this example, state
can be "success"
or "error"
, but it cannot be any other value.
Combining Types with Shared Properties
When we use the |
operator to combine types that share some properties, these properties are retained in the union only if they are common to all included types. Let's look at an example to better understand this concept:
interface Dog {
type: 'dog';
barks: boolean;
}
interface Cat {
type: 'cat';
meows: boolean;
}
type Animal = Dog | Cat;
function processAnimal(animal: Animal) {
// We can only access the 'type' property common to both types
console.log(animal.type);
// This would raise an error, as 'barks' or 'meows' depend on the specific type
// console.log(animal.barks); // Error: 'barks' does not exist on type 'Animal'.
// console.log(animal.meows); // Error: 'meows' does not exist on type 'Animal'.
}
let myDog: Dog = { type: 'dog', barks: true };
let myCat: Cat = { type: 'cat', meows: true };
processAnimal(myDog); // Expected output: "dog"
processAnimal(myCat); // Expected output: "cat"
In this example, Animal
is a union of Dog
and Cat
. Although both types have the type
property, specific properties like barks
and meows
are only available when working with a specific type (Dog
or Cat
), not in the Animal
type as a whole.
The & (Intersection) Operator
On the other hand, the &
operator in TypeScript is used to create a type that has all the properties of the types we are combining. That is, TypeA & TypeB
means a type that has all the properties of TypeA
and all the properties of TypeB
.
interface Person {
name: string;
}
interface Employee {
salary: number;
}
type NamedEmployee = Person & Employee;
let employee: NamedEmployee = {
name: 'John',
salary: 3000,
};
In this case, NamedEmployee
is a type that has both name
and salary
, combining the properties of Person
and Employee
.
Using | and & Together
We can combine |
and &
to create even more complex and specific types according to our needs:
type Options = { mode: 'modeA' | 'modeB' } & { size: 'small' | 'large' };
let configuration: Options = {
mode: 'modeA',
size: 'small',
};
In this example, configuration
must have both mode
(which can be "modeA"
or "modeB"
) and size
(which can be "small"
or "large"
).
Key Differences
|
(Union): Used to combine types where a value can be any of those types.&
(Intersection): Used to combine types where a value must have all the properties of those types.
Understanding typeof
in TypeScript
typeof
in TypeScriptNow let's see the use of the typeof
operator in TypeScript and how it can help us handle complex types more efficiently.
Concept of typeof
In TypeScript, typeof
is an operator that allows us to refer to the type of a variable, property, or expression at compile time. This operator returns the static type of the expression to which it is applied. It is very useful when we need to refer to an existing type instead of defining it explicitly.
let x = 10;
let y: typeof x; // y will be of type 'number'
In the above example, typeof x
is evaluated as the type of the variable x
, which is number
. This allows us to assign x
's type to another variable y
without having to specify it manually.
Utilization for Complex Types
One of the biggest advantages of typeof
is its ability to handle complex types more clearly and concisely. For example, when working with types that are the result of complex unions or intersections, we can use typeof
to capture those types efficiently.
interface Person {
name: string;
age: number;
}
type Employee = {
id: number;
position: string;
} & typeof myPerson; // Captures the type of 'myPerson'
const myPerson = { name: 'John', age: 30 };
let employee: Employee;
employee = { id: 1, position: 'Developer', name: 'John', age: 30 };
In this example, typeof myPerson
captures the type of myPerson
, which is { name: string; age: number; }
. Then, this type is combined (&
) with the additional properties of Employee
. This allows us to define Employee
in a way that directly leverages myPerson
's type without having to repeat its structure.
Benefits of typeof
- Reflects Changes Automatically: If we modify
myPerson
, the type ofEmployee
will automatically adjust to reflect those changes. - Avoids Code Duplication: We don't need to manually define the structure of
Person
twice;typeof
ensures consistency. - Simplified Maintenance: When
myPerson
's type changes, uses oftypeof
are updated automatically, reducing errors and maintenance time.
Exploring as const
in TypeScript
as const
in TypeScriptThis operator can help us define immutable constant values, improving the security and accuracy of our code. Let's see how it works and how we can use it to get the most out of it.
What is as const
?
The as const
operator tells TypeScript to treat the value of an expression as a literal constant. This means that each value will be considered immutable, and its type will be reduced to its most specific possible form. This is particularly useful when we want to ensure that values do not change in the future.
Basic Example
Let's start with a simple example to see how as const
works:
let colors = ['red', 'green', 'blue'] as const;
Without as const
, colors
would be of type string[]
, allowing any string in the array. But by using as const
, colors
becomes a specific literal type: readonly ["red", "green", "blue"]
. Now, TypeScript knows that colors
contains exactly those three elements and nothing else.
Application to Objects
The use of as const
is not limited to arrays; it can also be applied to objects. This is especially useful when working with configurations or data that should not change.
const configuration = {
mode: 'production',
version: 1.2,
options: {
debug: false,
},
} as const;
In this case, the configuration
object has an immutable type with the exact values we have defined. This means that configuration.mode
is of type "production"
, configuration.version
is of type 1.2
, and configuration.options.debug
is of type false
.
Benefits of as const
- Immutability: Values cannot be changed, preventing accidental errors.
- Literal Types: Types are reduced to their most specific forms, improving type precision.
- Type Safety: Ensures that values do not change at runtime, providing greater code security.
Use in Functions
Let's see how as const
can improve accuracy in the context of functions:
function getConfig() {
return {
mode: 'production',
version: 1.2,
options: {
debug: false,
},
} as const;
}
const config = getConfig();
// config.mode is "production", not string
// config.version is 1.2, not number
// config.options.debug is false, not boolean
Here, the getConfig
function returns an object whose type is immutable thanks to as const
. This ensures that the returned values have the most specific types possible.
Return Type
The ReturnType
utility type in TypeScript is a powerful tool that allows us to infer the return type of a function. This is especially useful when working with complex functions and we want to ensure that the return type is handled consistently throughout our code.
Using ReturnType
ReturnType
takes a function type and returns the type of the value that the function returns. Let's see an example of how this can be used in a real-world context.
Suppose we are working on a task management application. We have a function that creates a new task, and we want to reuse the return type of this function in other parts of our code.
// Definition of the function that creates a new task
function createTask(title: string, description: string) {
return {
id: Math.random().toString(36).substr(2, 9),
title,
description,
completed: false,
};
}
// Using ReturnType to infer the return type of createTask
type Task = ReturnType<typeof createTask>;
// Now we can use the Task type in other parts of our code
const newTask: Task = createTask(
'Buy milk',
'Go to the supermarket and buy milk',
);
console.log(newTask);
// Implementation of a function that marks a task as completed
function completeTask(task: Task): Task {
return { ...task, completed: true };
}
const completedTask = completeTask(newTask);
console.log(completedTask);
In this example:
- We define a function
createTask
that returns an object representing a new task. - We use
ReturnType
to create aTask
type that represents the type of the value returned bycreateTask
. - We use the
Task
type to declare variables and return types in other functions.
This ensures that if the structure of the object returned by createTask
changes, TypeScript will automatically update the Task
type, keeping our code consistent and preventing errors.
Advantages of Using ReturnType
- Consistency: Ensures that the return type of a function is handled consistently throughout the code.
- Maintenance: Makes code maintenance easier, as any changes to the original function will automatically be reflected in the inferred type.
- Error prevention: Helps prevent type errors that can arise from manually copying complex return types.
ReturnType
is an extremely useful tool in TypeScript for handling complex return types and keeping our code clean and safe. Use it whenever you need to reuse the return type of a function in various parts of your code.
Adventure in TypeScript: Type Assertion and Type Casting
Let's explore how to use them correctly, the precautions we should take, and the crucial difference between unknown
and any
. Let's dive in!
What is Type Assertion?
Type Assertion is a way to tell TypeScript to treat a variable as if it were of a specific type. It's like telling the compiler: "Trust me, I know what I'm doing." This can be useful in situations where we are sure of a variable's type, but TypeScript cannot infer it correctly.
There are two main syntaxes for Type Assertion in TypeScript:
-
Using the
as
operator:let value: any = 'This is a string'; let length: number = (value as string).length;
-
Using the
<type>
operator:let value: any = 'This is a string'; let length: number = (<string>value).length;
Both syntaxes achieve the same result, but as
is more commonly used in modern TypeScript code, especially when working with JSX in React.
Precautions with Type Assertion
Type Assertion is powerful, but it can also be dangerous if used incorrectly. Here are some things to keep in mind:
-
Confidence in the Type: Ensure that the assertion is valid. If you get it wrong, you can introduce hard-to-detect runtime errors.
let value: any = 'This is a string'; let number: number = value as number; // Runtime error!
-
Avoid Unnecessary Assertions: Do not use Type Assertion if TypeScript can infer the type correctly.
let value = 'This is a string'; // TypeScript infers 'value' as string let length: number = value.length; // No need for Type Assertion
Type Casting in TypeScript
Type casting is similar to Type Assertion but often refers to converting one type to another at runtime, something more common in languages like C# or Java. In TypeScript, casting is generally achieved through conversion functions.
Example:
let value: any = '123';
let number: number = Number(value); // Casting from string to number
Although TypeScript is a superset of JavaScript, it does not add explicit casting features, relying instead on JavaScript conversion functions.
unknown
vs any
: Know the Difference
any
and unknown
are two special types in TypeScript that allow working with values of any type, but they have key differences in their use and safety.
-
any
:- Allows any value to be assigned to a variable.
- Disables all type checks, which can lead to runtime errors.
- Should be used sparingly.
let value: any = 'This is a string'; value = 42; // No error, but may cause runtime issues value.nonExistentMethod(); // No compile-time error, but will fail at runtime
-
unknown
:- Also allows any value to be assigned to a variable.
- Forces type checks before accessing properties or methods, making the code safer.
let value: unknown = 'This is a string'; if (typeof value === 'string') { console.log(value.length); // Safe, TypeScript knows it's a string } // value.nonExistentMethod(); // Compile-time error
Combined Example
Let's see an example that combines Type Assertion, any
, and unknown
:
function processValue(value: unknown) {
if (typeof value === 'string') {
let length = (value as string).length;
console.log(`The length of the string is ${length}`);
} else if (typeof value === 'number') {
let double = (value as number) * 2;
console.log(`The double of the number is ${double}`);
} else {
console.log('The value is neither a string nor a number');
}
}
let anyValue: any = 'Text';
processValue(anyValue);
anyValue = 100;
processValue(anyValue);
anyValue = true;
processValue(anyValue); // The value is neither a string nor a number
In this example, we use unknown
to receive values of any type, then check their type before performing specific operations. We also show how any
can be flexible but should be handled carefully to avoid errors.
Functional Overloading in TypeScript: Pure Magic
Hello, devs! Today we are going to delve into the fascinating world of functional overloading in TypeScript. Let's explore how we can define multiple signatures for a function and how to use types so that our functions change their output based on the input parameter type. Let's get started!
What is Functional Overloading?
In TypeScript, functional overloading allows us to define multiple signatures for a function so that it can accept different types of arguments and behave differently based on the input type.
This is particularly useful when we have a function that can operate in different ways depending on the parameters it receives.
Basic Syntax
The basic syntax for defining function overloading in TypeScript includes several function signatures followed by an implementation that covers all cases.
function myFunction(param: string): string;
function myFunction(param: number): number;
function myFunction(param: boolean): boolean;
// Implementation that covers all overloads
function myFunction(
param: string | number | boolean,
): string | number | boolean {
if (typeof param === 'string') {
return `String received: ${param}`;
} else if (typeof param === 'number') {
return param * 2;
} else {
return !param;
}
}
// Using the overloaded function
console.log(myFunction('Hello')); // String received: Hello
console.log(myFunction(42)); // 84
console.log(myFunction(true)); // false
In this example, myFunction
can accept a string
, number
, or boolean
and will behave differently based on the argument type.
Practical Examples
-
Function to manipulate arrays and strings:
function manipulate(data: string): string[]; function manipulate(data: string[]): string; function manipulate(data: string | string[]): string | string[] { if (typeof data === 'string') { return data.split(''); } else { return data.join(''); } } // Using the overloaded function console.log(manipulate('Hello')); // ['H', 'e', 'l', 'l', 'o'] console.log(manipulate(['H', 'e', 'l', 'l', 'o'])); // "Hello"
-
Function to handle different types of inputs and produce different outputs:
function calculate(input: number): number; function calculate(input: string): string; function calculate(input: number | string): number | string { if (typeof input === 'number') { return input * input; } else { return input.toUpperCase(); } } // Using the overloaded function console.log(calculate(5)); // 25 console.log(calculate('hello')); // "HELLO"
Important Considerations
- Unified Implementation: The function implementation must be able to handle all the types of parameters defined in the overload signatures.
- Compatible Return Type: The return type must be compatible with all the types defined in the overload signatures.
- Proper Use of Type Guards: It is essential to use type guards (
typeof
,instanceof
) correctly to ensure that the implementation handles each type appropriately.
Case with Complex Types
Function Overloading with Complex Types
First, we define our interfaces for the complex types Cat
and Dog
, which extend a base interface Animal
.
interface Animal {
type: string;
sound(): void;
}
interface Cat extends Animal {
type: 'cat';
breed: string;
}
interface Dog extends Animal {
type: 'dog';
color: string;
}
Next, we define the overload declarations for the processAnimal
function to specify the input types and the output types.
function processAnimal(animal: Cat): string;
function processAnimal(animal: Dog): number;
Then, we implement the processAnimal
function using function overloading. Depending on whether the parameter is a Cat
or a Dog
, the function will return a string
or a number
, respectively.
function processAnimal(animal: Cat | Dog): string | number {
if ('breed' in animal) {
// The object is a cat
console.log(`It's a ${animal.breed} cat`);
animal.sound();
return animal.breed;
} else {
// The object is a dog
console.log(`It's a ${animal.color} dog`);
animal.sound();
return animal.color.length;
}
}
Implementing the Types
We create instances of Cat
and Dog
and use the processAnimal
function to process these objects. Depending on the type of object, the function will return a string
or a number
.
const myCat: Cat = {
type: 'cat',
breed: 'Siamese',
sound: () => console.log('Meow'),
};
const myDog: Dog = {
type: 'dog',
color: 'Black',
sound: () => console.log('Woof'),
};
const catResult = processAnimal(myCat); // Output: It's a Siamese cat \n Meow
const dogResult = processAnimal(myDog); // Output: It's a Black dog \n Woof
console.log(catResult); // Output: Siamese
console.log(dogResult); // Output: 5
In this example, processAnimal(myCat)
will return the cat's breed as a string
, while processAnimal(myDog)
will return the length of the dog's color as a number
.
Additional Example: Overloading with Property Checks
Now, let's see another example using function overloading and property checks with the in
operator.
interface Vehicle {
type: string;
maxSpeed(): void;
}
interface Car extends Vehicle {
type: 'car';
brand: string;
}
interface Bicycle extends Vehicle {
type: 'bicycle';
brakeType: string;
}
function describeVehicle(vehicle: Car): string;
function describeVehicle(vehicle: Bicycle): boolean;
function describeVehicle(vehicle: Car | Bicycle): string | boolean {
if ('brand' in vehicle) {
// The object is a car
console.log(`It's a ${vehicle.brand} car`);
vehicle.maxSpeed();
return vehicle.brand;
} else {
// The object is a bicycle
console.log(`It's a bicycle with ${vehicle.brakeType} brakes`);
vehicle.maxSpeed();
return vehicle.brakeType.length > 5;
}
}
const myCar: Car = {
type: 'car',
brand: 'Toyota',
maxSpeed: () => console.log('200 km/h'),
};
const myBicycle: Bicycle = {
type: 'bicycle',
brakeType: 'disc',
maxSpeed: () => console.log('30 km/h'),
};
const carResult = describeVehicle(myCar); // Output: It's a Toyota car \n 200 km/h
const bicycleResult = describeVehicle(myBicycle); // Output: It's a bicycle with disc brakes \n 30 km/h
console.log(carResult); // Output: Toyota
console.log(bicycleResult); // Output: false
In this example, describeVehicle(myCar)
will return the car's brand as a string
, while describeVehicle(myBicycle)
will return a boolean
indicating whether the length of the bicycle's brake type is greater than 5 characters.
TypeScript Utilities: Essential Helpers
TypeScript offers a variety of utility types that facilitate the manipulation and management of complex types. These helpers allow transforming, filtering, and creating new types based on existing types. Below, we will explore some of the most common helpers and how they can be used in daily development.
Partial
Partial<T>
converts all the properties of a type T
into optional properties. It is useful when we want to work with incomplete versions of a type.
interface User {
name: string;
age: number;
email: string;
}
const partialUser: Partial<User> = {
name: 'John',
};
Required
Required<T>
converts all the properties of a type T
into required properties. It is the opposite of Partial
.
interface Configuration {
darkMode?: boolean;
notifications?: boolean;
}
const completeConfig: Required<Configuration> = {
darkMode: true,
notifications: true,
};
Readonly
Readonly<T>
converts all the properties of a type T
into read-only properties.
interface Book {
title: string;
author: string;
}
const book: Readonly<Book> = {
title: '1984',
author: 'George Orwell',
};
// book.title = 'Animal Farm'; // Error: cannot assign to 'title' because it is a read-only property.
Record
Record<K, T>
constructs a type of object whose properties are keys of type K
and values of type T
.
type Role = 'admin' | 'user' | 'guest';
const permissions: Record<Role, string[]> = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
};
Pick
Pick<T, K>
creates a type by selecting a subset of the properties K
from a type T
.
interface Person {
name: string;
age: number;
address: string;
}
const personNameAge: Pick<Person, 'name' | 'age'> = {
name: 'Maria',
age: 30,
};
Omit
Omit<T, K>
creates a type by omitting a subset of the properties K
from a type T
.
interface Product {
id: number;
name: string;
price: number;
}
const productWithoutId: Omit<Product, 'id'> = {
name: 'Laptop',
price: 1500,
};
Exclude
Exclude<T, U>
excludes from T
the types that are assignable to U
.
type NumbersOrString = string | number | boolean;
type OnlyNumbersOrString = Exclude<NumbersOrString, boolean>; // string | number
Extract
Extract<T, U>
extracts from T
the types that are assignable to U
.
type Types = string | number | boolean;
type OnlyBooleans = Extract<Types, boolean>; // boolean
NonNullable
NonNullable<T>
removes null
and undefined
from a type T
.
type PossiblyNull = string | number | null | undefined;
type NoNulls = NonNullable<PossiblyNull>; // string | number
ReturnType
ReturnType<T>
obtains the return type of a function T
.
function getUser(id: number) {
return { id, name: 'John' };
}
type User = ReturnType<typeof getUser>; // { id: number, name: string }
Complete Example
Let's see a practical example using several of these helpers together:
interface User {
id: number;
name: string;
email?: string;
address?: string;
}
// Convert all properties to optional
type PartialUser = Partial<User>;
// Convert all properties to required
type RequiredUser = Required<User>;
// Create a read-only type
type ReadonlyUser = Readonly<User>;
// Select only some properties
type BasicUser = Pick<User, 'id' | 'name'>;
// Omit some properties
type UserWithoutId = Omit<User, 'id'>;
// Create a record of roles to permissions
type Role = 'admin' | 'editor' | 'reader';
const permissions: Record<Role, string[]> = {
admin: ['create', 'read', 'update', 'delete'],
editor: ['create', 'read', 'update'],
reader: ['read'],
};
// Exclude types
type ID = string | number | boolean;
type IDWithoutBooleans = Exclude<ID, boolean>; // string | number
// Extract types
type OnlyBooleans = Extract<ID, boolean>; // boolean
// Remove null and undefined
type MayBeNull = string | null | undefined;
type NotNull = NonNullable<MayBeNull>; // string
Generics in TypeScript
Generics in TypeScript are a powerful tool that allows creating reusable and highly flexible components. Generics provide a way to define types in a way that is not yet determined, allowing functions, classes, and types to work with any type specified at the time of the call or instantiation. Below, we will explore the basics and advanced concepts of generics in TypeScript, including practical examples.
Basic Concepts
Generics are declared using the angle notation <T>
, where T
is a generic type parameter. This type parameter can be any letter or word, although T
is commonly used.
Generic Functions
Generic functions allow working with any type of data without sacrificing typing.
function identity<T>(value: T): T {
return value;
}
const numberValue = identity<number>(42); // 42
const textValue = identity<string>('Hello World'); // 'Hello World'
Generic Classes
Generic classes allow creating data structures that can work with any type.
class Box<T> {
content: T;
constructor(content: T) {
this.content = content;
}
getContent(): T {
return this.content;
}
}
const numberBox = new Box<number>(123);
console.log(numberBox.getContent()); // 123
const textBox = new Box<string>('Text');
console.log(textBox.getContent()); // 'Text'
Generic Interfaces
Generic interfaces allow defining contracts that can adapt to different types.
interface Pair<K, V> {
key: K;
value: V;
}
const numberTextPair: Pair<number, string> = { key: 1, value: 'One' };
const textBooleanPair: Pair<string, boolean> = { key: 'active', value: true };
Advanced Use of Generics
Generic Constraints
We can restrict the types that a generic can accept using extends
.
interface WithName {
name: string;
}
function greet<T extends WithName>(obj: T): void {
console.log(`Hello, ${obj.name}`);
}
greet({ name: 'John' }); // Hello, John
// greet({ lastName: 'Doe' }); // Error: object does not have 'name' property
Generics in Higher-Order Functions
We can use generics in functions that accept and return other functions.
function process<T>(items: T[], callback: (item: T) => void): void {
items.forEach(callback);
}
process<number>([1, 2, 3], number => console.log(number * 2)); // 2, 4, 6
Complete Example with Complex Types and in
Next, we will combine what we have learned about generics with an advanced type check using the in
keyword.
interface Animal {
type: string;
sound(): void;
}
interface Cat extends Animal {
type: 'cat';
breed: string;
}
interface Dog extends Animal {
type: 'dog';
color: string;
}
function processAnimal<T extends Animal>(animal: T): string {
if ('breed' in animal) {
return `It's a ${animal.breed} cat`;
} else if ('color' in animal) {
return `It's a ${animal.color} dog`;
} else {
return `It's an animal of unknown type`;
}
}
const myCat: Cat = {
type: 'cat',
breed: 'Siamese',
sound: () => console.log('Meow'),
};
const myDog: Dog = {
type: 'dog',
color: 'Black',
sound: () => console.log('Woof'),
};
console.log(processAnimal(myCat)); // Output: It's a Siamese cat
console.log(processAnimal(myDog)); // Output: It's a Black dog
In this example, processAnimal
is a generic function that can process any type of animal that extends from Animal
. We use the in
keyword to check for the existence of a property and thus determine the exact type of the object.
The Magic of Enums
First, we define two enums. Enums are those friends who always bring something useful to the party. They allow us to group named constants so that we don't have to guess what each value means.
enum Numbers1 {
'NUMBER1' = 'number1',
'NUMBER2' = 'number2',
}
enum Numbers2 {
'NUMBER3' = 'number3',
}
Combining Superpowers
Now, we mix these two enums into a single object. For this, we use the spread operator (...
). And note, as const
is the key here to make TypeScript treat this object as an immutable constant.
const myNumbers = { ...Numbers1, ...Numbers2 } as const;
const mixValues = Object.values(myNumbers);
Derived Types from Combined Enums
And now what? Well, now we use typeof
and [number]
to create a type that represents the combined values of the enums. How is this?
type MixNumbers = (typeof mixValues)[number];
But, why [number]
?
Good question, dear reader. When we do Object.values(myNumbers)
, we get an array of values. So, typeof mixValues
gives us the type of this array, which is string[]
in our case. By using [number]
, we are saying "I want the type of the elements inside this array". It's like telling TypeScript: "Hey, give me the type of what's inside, not the container."
Now, the more technical and precise reason: enums in TypeScript generate an internal structure that uses both keys and values to create a kind of bi-directionality. This means that in the enum object, each value has an automatically associated numeric key. When we use [number]
, we are leveraging this feature to get the type of the values being indexed numerically.
Creating a Type Based on Our Values
Finally, we create a type Enums
that uses a mapped index to define properties based on the values of MixNumbers
. Each property can be of any type (any
), because sometimes life is just that flexible.
type Enums = {
[key in MixNumbers]: any;
};
The Complete Example
Let's see the full code in action:
enum Numbers1 {
'NUMBER1' = 'number1',
'NUMBER2' = 'number2',
}
enum Numbers2 {
'NUMBER3' = 'number3',
}
const myNumbers = { ...Numbers1, ...Numbers2 } as const;
const mixValues = Object.values(myNumbers);
type MixNumbers = (typeof mixValues)[number];
type Enums = {
[key in MixNumbers]: any;
};
// Usage example
const example: Enums = {
number1: 'This is number 1',
number2: 42,
number3: { detail: 'Number 3 as an object' },
};
console.log(example);
Code Breakdown
- Enum Definition:
Numbers1
andNumbers2
are our initial superheroes, each with their own powers. - Enum Combination: We mix our heroes' powers into a single team using
...
andas const
. - Creating Derived Types: We use
typeof
and[number]
to create a type that represents the combined values. - Defining the Enums Type: We use a mapped index to define properties based on
MixNumbers
.