Nie ruszymy dalej z materiałem ani projektem, jeżeli nie poznamy bardzo ważnego i trudnego (dla fanów języków typu JS/PHP) zagadnienia, czyli typów generycznych. Zaczynajmy.
Ok, na początek takie dwa przykłady:
function first<T>(arr: T[]){
return arr[0];
}
const last = <T>(arr: T[]) => {
return arr[arr.length - 1]
}
Tak to wygląda od strony składni, teraz użycie:
console.log(first(['a', 'b', 'c'])); //a
console.log(first([1,2,3])); //1
console.log(first([])); //undefined
console.log(last(['a', 'b', 'c'])); //c
console.log(last([1,2,3])); //3
console.log(last([])); //undefined
Rzecz pierwsza – ktoś może pomyśleć, że po co to nam, można zrobić arr type any. Można, ale wtedy będzie można mieszać typy. My dajemy możliwość wyboru typu, ale trzeba się go trzymać do końca.
Druga rzecz – te puste tablice. Cóż, można ustawić sobie typ, który jest niepustą tablicą:
// type NonEmptyArray<T> = [T, ...T[]];
// type NonEmptyArray<T> = T[] & { 0: T }
function first<T>(arr: [T, ...T[]]){
return arr[0];
}
const last = <T>(arr: T[] & { 0: T }) => {
return arr[arr.length - 1]
Teraz pusta tablica nie przejdzie. Ok, kilka generycznych typów, które koniecznie muszą być obiektami:
function merge<T extends object, U extends object>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const mergedObj = merge({ name: 'John'}, { age: 30 });
console.log(mergedObj);
//Object { name: "John", age: 30 }
Ok, generyczny typ, który dziedziczy z interfejsu:
interface Lengthy {
length: number;
}
function isEmpty<T extends Lengthy>(element: T){
return element.length === 0;
}
console.log(isEmpty([])); //true
console.log(isEmpty({length: 0})); //true
console.log(isEmpty({length: 1, 0: 1})); //false
console.log(isEmpty([1,2,3])); //false
Swoją drogą tablice nam przechodzą. Bo każda tablica to obiekt, który posiada length.
Inny ciekawy przykład:
interface Named {
firstName: string;
lastName: string;
}
const addFullName = <T extends Named>(obj: T) => {
return {...obj,
fullName: obj.firstName + " " + obj.lastName
};
}
let namedObj: Named = {firstName: 'Jane', lastName: 'Doe'};
console.log(addFullName(namedObj));
//Object { firstName: "Jane", lastName: "Doe", fullName: "Jane Doe" }
Extends oznacza, że musi mieć te pola, co named (ale może mieć i więcej). Dajmy jeszcze jeden prosty przykład:
const makeArr = <T>(el: T): [T] => {
return [el];
}
Chyba tłumaczyć nie muszę. Ok, kolejny ciekawy przykładzik:
const isObj = <T>(arg: T) : boolean => {
return (
typeof arg === 'object' &&
!Array.isArray(arg) &&
arg !== null
);
}
console.log(isObj(null)); //false
console.log(isObj([])); //false
console.log(isObj(1)); //false
console.log(isObj("hello")); //false
console.log(isObj({})); //true
Tu swoją drogą warto wspomnieć, że ja zawsze zapominam, że to co z typeof porównujemy musi być stringiem… Nie wiem, czy tylko ja tak mam. Warto też się pilnować ze średnikami i nawiasami przy takich returnach, bo ASI może nam zepsuć kod (nie wiem jak w TS, w JS psuje często przy takich returnach).
Ok, to teraz coś, co nie jest typami generycznymi, ale musimy to poznać, czyli literal types:
type ValidProtocol = "https://" | "http://"
type ValidUrl = `${ValidProtocol}jsonplaceholder.typicode.com/todos/${number}`;
const url: ValidUrl = 'https://jsonplaceholder.typicode.com/todos/1';
Magia, prawda? To z jakiegoś kursu było, zróbmy sobie fetch:
function fetchData(url: ValidUrl){
fetch(url)
.then(res => res.json())
.then(data =>console.log(data))
}
fetchData(url);
Od razu mówię, fetchem się więcej pobawimy, jak poznamy utility types. Tym niemniej, rzućmy okiem na tę funkcję:
interface Todo1 {
id: number;
title: string;
completed: boolean;
}
const logTodo = <T extends Todo1> (obj: T) => {
console.log(`
The Todo with ID: ${obj.id}
Has a title of: ${obj.title}
Is it finished? ${obj.completed}
`);
};
logTodo({id: 1, completed: true, title: "blabla", sthelse: 'blabla'})
Pytanie – po co to nam? Cóż, gdybyśmy zamiast typu generycznego zrobili obj: Todo1, to nie byłoby możliwe dodać klucz sthelse… I ciekawe też jest to, że z fetcha daje radę, choć tam jest więcej, niż trzy te klucze, a z kodu nie (przy braku generycznej funkcji).
Dlaczego z fetcha daje radę? Cóż, bo TS nie wie, co nam ten fetch zwróci. To już się w runtime dzieje. Tym się też zajmiemy jak poznamy utility types. Tym niemniej, gdybyśmy olali generyczny typ i zrobili anotację obj: Todo1 to z fetcha nam przejdzie (bo kompilator nie wie, co fetch zwróci), z kodu nie (bo od razu widzi co i jak).
Ewentualnie, gdybyśmy mieli inaczej kompilator ustawiony, zawsze możemy użyć słówka kluczowego as, np. as Todo1. To pokazuje, że choć nie wiemy, co zostanie zwrócone, mówimy, że będzie to typ Todo1. Tak samo jest z querySelectorami, które też nie wiadomo, co zwrócą. Ustawienia kompilatora mamy w tsconfig, ja tam jeszcze mimo wszystko wolę luźniejsze ustawienia i as nie muszę używać.
Ok, a czemu w zasadzie Todo1? Skąd to 1? Cóż, ja mam projekt TS a nie każdy plik z osobna, i już gdzieś mam taki interfejs. To tak, jakby ktoś się dziwił, czemu taka nazwa. Tsc init robi projekt.
Ok, fajnie, fajnie, ale wracajmy do tematu, bo jeszcze wiele przed nami. Keyof poznajmy:
logTodo({id: 1, completed: true, title: "blabla", sthelse: 'blabla'})
const getObjKeyVal = <T extends object, K extends keyof T> (obj: T, key: K) =>
{
return obj[key];
}
let someObj = {name: "John", age: 30}
console.log(getObjKeyVal(someObj, 'name')); //John
console.log(getObjKeyVal(someObj, 'age')); //30
Tutaj gdybyśmy podali coś co nie jest kluczem tego obiektu (np. firstName zamiast name) to mamy błąd od razu. To właśnie oznacza extends keyof T.
Ok, poznajmy klasę generyczną, plus dziwactwo TSa, że settery nie mogą mieć zwracanego typu (nawet void):
class StateObject<T> {
private data: T;
constructor(value: T){
this.data = value;
}
get state() : T {
return this.data;
}
set state(value: T) {
this.data = value;
}
}
const store = new StateObject("Jane");
console.log(store.state);
store.state = "Jim";
console.log(store.state);
Jak już raz ustalimy typ (np. string) to już dalej nie idzie nadpisać na inny typ, np. number. Settery nie mogą nic zwracać. Z innej beczki, pragnę przypomnieć, że wiele metod Reflect API JSa zwraca boolean.
Ok, nieważne. Zobaczmy na ten przykład:
class Attributes2<T extends object> {
constructor(private data: T) {}
get = <K extends keyof T>(key: K): T[K] => {
return this.data[key];
};
set(update: T): void {
Object.assign(this.data, update);
}
getAll(): T {
return this.data;
}
}
let attrs2 = new Attributes2({'a': 'sth', 'b': 'sth else'});
console.log(attrs2.get('a')); //sth
console.log(attrs2.get('b')); //sth else
console.log(attrs2.getAll()); //Object { a: "sth", b: "sth else" }
Tu po pierwsze nie mamy akcesorów tylko metody tak nazwane (get, set), to są metody. Po drugie i klasa jest generyczna i metoda get jest generyczna i wymaga podania klucza, który figuruje w obiekcie data, który jest jeszcze private.
Do settera też wymagamy, aby przekazać coś, co jest obiektem. Jeszcze warto dodać, że anotacje typów też mogą być używane w generykach:
type BasicType = string | number | boolean;
class DataStorage<T extends BasicType> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
if (this.data.indexOf(item) === -1) {
return;
}
this.data.splice(this.data.indexOf(item), 1);
}
getItems() {
return [...this.data];
}
}
W ogóle, my to robimy, bo mutujemy tablicę. Z typami referencyjnymi inaczej byśmy się za to mutowanie zabrali, nieważne, oto przykład użycia:
const textStorage = new DataStorage<string>();
textStorage.addItem('John');
textStorage.addItem('Jane');
textStorage.removeItem('John');
console.log(textStorage.getItems()); //Array [ "Jane" ]
Teraz może kusić, aby pomyśleć, żeby dać jakiś constraint dla removeItem. Aby nie dało się przekazać czegoś, co nie jest wartością w data. I tutaj, 100% nie jestem pewien, bo moja bajka to języki bez ścisłego typowania i TSem bawię się od niedawna. Ale myślę, że się nie da.
Po pierwsze, TS jest do kompilacji. Dodawanie czegoś przez addItem to runtime. I proszę wierzyć, niedawno to jeszcze można było „hakować” TSa poprzez pushowanie do tablicy określonego typu danych innego typu.
Nie wierzę, że TS jest tak inteligentny, że można taki constraint walnąć, który rozpozna, że chcemy usunąć item, który nie jest wartością data. Zobaczę, to uwierzę, też rad bym był lepiej tego TSa poznać i myślę, że jestem na dobrej drodze.
Ok, starczy, i tak mamy dużo materiału. Niedługo będzie więcej o typach, utility types i innych, ciekawych zagadnień TSa. Dekoratory na chwilę odkładamy, zwłaszcza, że wyszły nowe dekoratory. Plus meta programowanie to chyba najtrudniejsze zagadnienie, po frameworkach typu Angular widzimy, jaki piękny potrafi być efekt, ale do tego czasu można wyrwać wszystkie włosy z głowy, zanim się to napisze.
Więcej TSa niedługo! Ciężko to do głowy wchodzi, ale nie traćmy zapału.