Introducere în TypeScript
Vom face o scurtă prezentatare a limbajului TypeScript pentru a dezvolta aplicații de frontend. Modul de lucru cu Typescript este în mare același ca cu JavaScript dar beneficiază de faptul că sintaxa este constrânsă de tipurile de date declarate. Existența tipurilor de date, chiar dacă în acest caz sunt verificate doar la faza de transpilare (transpiling) unde codul de TypeScript este transformat în cod normal de JavaScript, ajută dezvoltatorii în a omite greșeli frecvente în limbaje slab tipate. Vom trece prin sintaxa specifică de TypeScript, pentru cei familiarizați cu limbaje C-like și orientate pe obiect sintaxa este ușor de învașat însă diferă în modul de utilizare față de aceste limbaje.
Variabile și constante
În JavaScript/TypeScript pentru a declara varibile și constante se folosesc cuvintele cheie let respectiv const, diferenșa fiind că variabilele declarate cu let pot fi reasignate. Există și cuvantul cheie var însă folosirea acestuia este dezcurajată deoarece variabilele declarate în acest mod există într-un scop global în interiorul programului și nu intr-un scop local, lucru ce conduce la probleme grave de securitate.
const constant = 5;
let value = 10;
value += constant;
console.log("Assigned value is: ", value);
// output - Assigned value is: 15
Tipuri
Tipurile cu care se lucrează în TypeScript sunt:
- number - valori numerice care pot fi numere întregi, numere în virgula mobilă sau NAN (Not A Number)
- string - șiruri de caractere declarate fie între "" sau între ''
- boolean - valori booleene cu singurele valori true și false
- array - vectori de orice tip (string[], number[], boolean[] etc) folosind [], acestea vin cu funcții clasice map, flatMap, reduce etc.
- null - valorile null reprezintă aici propriul tip ca să indice ca valoarea poate fi nulă
- undefined - este un tip special pentru a indica inexistența unui camp într-un obiect, parametru de funcție sau definerea unei variabile
- object - se declara folosind în care se pot adauga campuri cu nume și valorile aferente
- function - în JavaScript/TypeScript se pot declara atât funcții normale ca tip de date cât și functii lambda (numite și arrow functions)
- any - un tip nesigur care poate fi asignat la orice și să i se asigneze orice valoare
- unknown - este varianta mai sigură de la any, i se poate asigna orice valoare dar se poate asigna doar la o alta variabilă unknown
- never - este un tip fără valori la care se poate asigna orice valoare, este folosit, de exemplu, pentru a semnala ca o funcție nu returneaza niciodată și că aruncă excepție
- void - este folosit pentru a arata ca o funcție nu returnează
let booleanValue = true; // tipul variabilei este deperminat implicit ca boolean
let numericValue: number = 5; // tipul variabilei este declarată explicit
let maybeString = booleanValue ? "Hello" : null; // tipul variabilei va fi implicit string | null
let objectValue = { // declarăm un tip anonim { stringValue: string | null, length: number | undefined }
stringValue: maybeString, // implicit tipul acestui câmp va fi tipul variabilei
length: maybeString?.length // putem folosi operatorul ?. pentru a întoarce undefined dacă maybeString nu are o valuoare, tipul campului va fi number | undefined
}
let array: number[] = [1, 2, 3]; // implicit acest vector va contine valori de tipul valorilor declarate aici, dacă nu se declară explicit tipul atunci acest vector ar fi un tuplu [number, number, number]
function isOdd(value: number) { // declăram o funcție de tip (value: number) => boolean
return number % 2 === 0;
}
const isOddVariable: (value: number) => boolean = isOdd;
const isEven = (value: number): boolean => { return number % 2 === 0; }; // se asignează un lambda de tipul (value: number) => boolean
let isOddResult = isOddVariable(numericValue);
let isEvenResult = isEven(numericValue); // putem apela un lambda ca orice funcție normală
Declararea de noi tipuri
Am văzut cum putem definii tipuri de obiecte în mod anonim dar pentru o organizare a codului mai bună putem declara tipuri complexe folosind cuvintele cheie type și interface astfel:
type Point2D = { // declarăm un alias pentru acest tip de obiect
x: number
y: number
test: () => boolean
}
interface Point2D { // declăram o interfața de această formă
x: number
y: number
test: () => boolean
}
În principiu, atât aliasurile de tip cât și interfețele pot fi folosite pentru a declara "forma" unui obiect însă sunt cateva diferențe aici. Aliasurile de tip față de interfețe pot fi folosite pentru alte tipuri decât obiecte cum ar fi uniuni.
type ValueStringUnion = "value1" | "value2" | "value3"; // acest tip reprezintă șiruri de caractere doar cu aceste trei posibilități
type StringOrStringGet = string | () => string; // declarăm un tip care poate fi un șir de caractere sau o funcție care returneaza un șir
type StringAndStringSet = [string, (value: string) => void]; // declarăm un tip tuplu care conține un șir de caractere și o funcție care folosește un șir
Atât aliasurile de tip cât și interfețele pot fi extinse.
interface Point2D { x: number; y: number; }
interface Point3D extends Point2D { z: number; } // o interfață poate extinde altă interfață
type Point2D = { x: number; y: number; }
type Point3D = Point2D & { z: number; } // la fel și pentru alias de tip
interface Point2D { x: number; y: number; }
type Point3D = Point2D & { z: number; } // un alias de tip poate extinde și o interfață
type Point2D = { x: number; y: number; }
interface Point3D extends Point2D { z: number; } // la fel și interfața poate extinde un alias dacă nu este o uniune
Interfetele fata de aliasuri de tip pot fi imbinate ca de exemplu:
interface Point2D { x: number; }
interface Point2D { y: number; }
const point: Point2D = { x: 0, y: 0 }; // nu dă eroare, interfața finală va fi combinată din ambele declarări
Trebuie să mentionăm aici că indiferent dacă vorbim de alias de tip sau interfață, tipurile vor fi verificate și trebuie sa respecte constrangerile de tip când sunt folosite, de exemplu, într-o funcție sau la asignare de variabile. De exemplu, dacă o funcție are ca parametru de tip string | number atunci poate primi ca parametru o variabilă de acest tip sau de tip string sau nubmer pentru că ambele sunt subtipuri ale acestei uniuni. Dacă în schimb tipul este { name: string } & { value: number } atunci în mod necesar valoarea primită trebuie să fie de forma { name: string, value: number } sau ceva ce extinde acest tip.
Clase
La fel ca în limbajele orientate pe obiect în JavaScript/TypeScript există și support pentru declararea claselor dar în general nu sunt atât de frecvent folosite. Vom mentiona doar cum se pot declara și folosi:
class PointImplementation implements Point2D { // poate implementa atât o interfață cât și un alias de tip
private name: string;
public x = 0;
public y = 0;
public constructor(name: string) {
this.name = name;
}
public getName(): string {
return this.name;
}
}
const point = new PointImplementation("NewPoint");
Enumerații
În TypeScript putem declara enumerații clasice pe lângă uniuni astfel:
enum Direction {
Left, // putem asigna valori precum Left = 1
Right
}
const directionLeft = Direction.Left; // putem adresa valoarea din enum în mod clasic
const directionRight = Direction["Right"]; // putem adresa valoare din enum ca într-un dictionar
const numbericValue: number = Direction.Left; // implicit valorile enumerațiilor sunt number
Implicit valorile enumerațiilor sunt numere dar putem să asignăm și șiruri de caractere:
enum Direction {
Left = "Left",
Right = "Right"
}
Operații pe obiecte
În JavaScript/TypeScript obiectele pot fi folosite într-un mod foarte dinamic. Acestea pot fi considerate până la urmă dicționare cheie-valoare, chiar și vectorii sunt obiecte. Din acest motiv putem crea pe loc obiecte asignând valori câmp cu câmp dar poate deveni problematic pentru obiecte mari. Pentru a simplifica lucrul cu obiecte se poate folosi operatorul ... (spread operator), cu acesta putem construi și deconstrui obiecte.
const oldObject = { name: "test", value: 10 };
const otherObject = { ...oldObject, otherName: "otherTest" }; // va fi { name: "test", value: 10, otherName: "otherTest" }, operatorul "..." vărsa câmpurile din vechiul obiect în cel nou
const newObject = { ...oldObject, name: "newTest" }; // va fi { name: "newTest", value: 10 }, valorile vărsate în noul obiect pot fi suprascrise cu alta valoare
const overrideObject = { name: "newTest", newValue: 15, ...oldObject }; // va fi { name: "test", value: 10, newValue: 15 }, suprascierea valorilor depinde de ordinea declarată
const { name } = oldObject; // putem deconstrui obiectul și să extragem o valoare în variabilă dată, name = "test"
const { name: otherName, ...rest } = oldObject // putem extrage restul obiectului în altă variablilă folosind "...", otherName = "test", rest = { value: 10 }
overrideObject.name = "newName"; // câmpurile din obiecte le putem adresa ca câmpuri într-o structură
overrideObject["name"] = "newName"; // sau putem adresa un camp din obiect ca într-un dicționar
const oldArray: string[] = ["a", "b", "c"];
const firstArray = [...oldArray, "d"]; // va fi ["a", "b", "c", "d"], putem folosi operatorul "..." și pe vectori
const secondArray = ["d", ...oldArray]; // va fi ["d", "a", "b", "c"], la fel, depinde de ordinea declarării cum se creează noul vector
const [a, b] = oldArray; // putem extrage din vector valori prin decontrucție, a = "a", b = "b"
const [value, ...otherRest] = oldArray; // putem extrage prin deconstrucție și restul vectorului în alt vector, value = "a", otherRest = ["b", "c"]
oldArray[0] = "e"; // la fel ca în alte limbaje se poate folosi adresare indexată pentru valorile din vectori
Genericitate
În foarte multe situații este nevoie de genericitate pentru a reduce cod duplicat. În general, genericitatea în TypeScript se foloseste la fel ca în alte limbaje cu support pentru genreicitate.
type GenericType<T> = { // putem declara atât interfețe cât și aliasuri de tip în mod generic
value: T;
}
function logValue<T extends { name: string }>(value: T) { // putem pune și constrângeri de tip pentru parametrul generic, aici trebuie să fie un obiect cu un câmp "name" de tip string
const { name, ...rest } = value;
console.log("Name is: ", name);
console.log("Rest is: ", JSON.stringify(rest));
}
const logValue = <T extends { name: string }>(value: T) { // putem declara și funcții lambda generice
const { name, ...rest } = value;
console.log("Name is: ", name);
console.log("Rest is: ", JSON.stringify(rest));
}
Egalitate
O cauza frecventă de eroari este datorată faptului că în JavaScript/TypeScript operatorul de egalitate == nu ține cont de tipul valorilor comparate. Din acest motiv trebuie folosit operatorul === pentru a evita divese probleme în cod. Același lucru se aplică și în cazul != și !==.
"" == false // true
"" === false // false
0 == false // true
0 === false // false
"0" == 0 // true
"0" === 0 // false
"0" == "" // false
"0" === "" // false
[] == false // true
[] === false // false
[] == 0 // true
[] === 0 // false
[] == "" // true
[] === "" // false
null == 0 // false
null === 0 // false
null == false // false
null === false // false
null == "" // false
null === "" // false
null == [] // false
null === [] // false
undefined == 0 // false
undefined === 0 // false
undefined == false // false
undefined === false // false
undefined == "" // false
undefined === "" // false
undefined == [] // false
undefined === [] // false
null == undefined // true
null === undefined // false
Module
Atunci când lucrăm cu JavaScript/TypeScript putem împărți codul în mai multe fișire sursă .js/.ts care să reprezinte diferite module. Modulele funcționeză ca niște obiecte singleton, sunt inițializate doar o singură dată. Într-un modul putem declara tipuri, variabile, constante și funcții și le putem exporta pentru a fi vizibile în alte module:
const logValue = (value: string) => console.log(value); // putem daclara variabile și să le exportăm
export { logValue }; // aici exportăm o valoare declarată anterior
export const logValue = (value: string) => console.log(value); // putem exporta direct o valoare
const logValue = (value: string) => console.log(value);
export default logValue; // putem exporta ca valoare implicită o valoare din modul
Simbolurile se pot importa in alte module astfel:
import { logValue } from "./logging" // se pot importa fisiere relativ la calea unde se află modulul curent, aici se importă simboluri neimplicite, fișierul importat este logging.ts
import logValue from "./logging" // aici se importă un simbol care este exportat implicit
Trebuie să nu existe dependențe ciclice între module. De exemplu, dacă modulul A
importă simboluri din modulul B
și invers, la încărcarea modulelor anumite simboluri vor fi undefined depinzănd de care dintre module este rezolvat primul. Evitați orice ar fi să aveți dependenșe ciclice între module.
Asincronicitate
Atunci când lucrăm cu cereri HTTP către un server vom avea fire de execuție care se vor executa în spatele aplicației de browser pentru a prelucra cererile de I/O. Din acest motiv în JavaScript/Typescript sunt suportate cuvintele cheie async și await pentru a lucra mai ușor cu funcții asincrone. Atunci când avem nevoie să cerem date de pe server va fi lansată o cerere HTTP care returnează un obiect Promise prin care se poate aștepta finalizarea acestuia folosind await dar numai într-o funcție asincronă marcata ca fiind async.
import { someAsyncFunction } from "AsyncFunction";
const syncCall = (): Promise<string | void> => {
return someAsyncFunction() // funcția asyncronă returneză un Promise
.then(e => e.json()) // putem face prelucrări pe datele din Promise o data ce apar
.catch(error => console.error(error)) // la eroare putem să prindem eroarea
.finally(() => console.log("Request finished!")); // și putem să executam o instrucțiune finală, se returneaza tot un Promise
}
const asyncCall = async (): Promise<string | void> => { // aici declarăm funcția ca fiind asyncronă
try { // într-o funcție asincronă putem aștepta un Promise cu date
const result = await someAsyncFunction();
return result.json();
} catch (error) {
console.error(error);
} finally {
console.log("Request finished!");
}
}