Poznajemy lepiej OOP w TypeScript plus kilka innych rzeczy. Zbieramy wiedzę do kupy, jeszcze kilka lekcji i będziemy mogli swobodnie używać TSa. Do dzieła.
Ok, pierwsza rzecz to przypomnienie różnicy między static i non static field:
class StaticField {
static _name = "John";
}
class NonStaticField {
name = "John";
}
To najlepiej zrozumieć na przykładzie jak to się kompiluje:
class StaticField {
}
StaticField._name = "John";
class NonStaticField {
constructor() {
this.name = "John";
}
}
Dodam, że nie mogliśmy tamtego static field nazwać name. Klasa to tak w zasadzie funkcja konstruktor i każda z nich ma własne property name, może pamietamy.
Ok, teraz private TSowe oraz JSowe. Najpierw TS private:
class TSPrivate {
private name: string;
constructor(name: string){
this.name = name;
}
}
let objTSPrivate = new TSPrivate("John");
objTSPrivate.name = 'Jane'; //error
Takie coś oznacza, że w TS nie możemy tej zmiennej zmieniać. Możemy ustawić getter i setter ale „po kropce” nie możemy zmieniać wartości, bo będzie błąd kompilacji (edytor nam to pokaże). Tak to wygląda w JS:
class TSPrivate {
constructor(name) {
this.name = name;
}
}
Ok, a JSPrivate? Zobaczmy na ten kod:
class JSPrivate {
#name: string;
constructor(name: string){
this.#name = name;
}
}
let objJSPrvate = new JSPrivate("John");
objJSPrvate.#name = 'Jane'; //też error
Też error, kto by pomyślał, po co nam to słowo kluczowe private zatem? Ale zobaczmy po kompilacji:
class JSPrivate {
constructor(name) {
_JSPrivate_name.set(this, void 0);
__classPrivateFieldSet(this, _JSPrivate_name, name, "f");
}
}
_JSPrivate_name = new WeakMap();
let objJSPrvate = new JSPrivate("John");
Ok, dodajmy getter i setter:
class JSPrivate {
#name: string;
constructor(name: string){
this.#name = name;
}
get name(){
return this.#name;
}
set name(val: string){
this.#name = val;
}
}
let objJSPrvate = new JSPrivate("John");
objJSPrvate.name = 'Jane'; //nie ma errora
Teraz możemy się bawić. Po kompilacji:
class JSPrivate {
constructor(name) {
_JSPrivate_name.set(this, void 0);
__classPrivateFieldSet(this, _JSPrivate_name, name, "f");
}
get name() {
return __classPrivateFieldGet(this, _JSPrivate_name, "f");
}
set name(val) {
__classPrivateFieldSet(this, _JSPrivate_name, val, "f");
}
}
_JSPrivate_name = new WeakMap();
let objJSPrvate = new JSPrivate("John");
objJSPrvate.name = 'Jane'; //nie ma errora
Ok, to teraz tak:
class JSPrivate {
#name: string;
constructor(name: string){
this.#name = name;
}
get name(){
return this.#name;
}
set name(val: string){
if(val.trim().length > 0){
this.#name = val;
}
}
}
let objJSPrvate = new JSPrivate("John");
objJSPrvate.name = ''; //nie ma errora, nadal jest John
W kompilacji nie widać żadnego problemu (niby czemu miałoby być widać), ale setter nie pozwala zmienić name na pusty string. Po kompilacji tak samo, nie możemy sobie zmienić.
Zachodzi pytanie, po co jest słówko private, skoro # robi nam prywatność zarówno w TS jak i JS? Cóż, private nie pozwala nam przed kompilacją coś nadpisywać po kropce, tylko tyle.
Przykładem gdzie przyda nam się private jest singleton (prywatny konstruktor i statyczne metody createInstance, getInstance):
class SingletonPerson {
static #instance : SingletonPerson;
public name: string;
private constructor(name: string){
this.name = name;
}
public static getInstance(){
if(SingletonPerson.#instance)
return SingletonPerson.#instance;
return new SingletonPerson("Unknown");
}
public static createInstance(name: string){
if(!SingletonPerson.#instance){
SingletonPerson.#instance = new SingletonPerson(name);
}
return SingletonPerson.#instance;
}
}
let singPers = SingletonPerson.createInstance("John");
console.log(singPers.name); //John
let singPersAgain = SingletonPerson.getInstance();
console.log(singPersAgain.name); //John
singPersAgain.name = 'Jim';
console.log(singPersAgain.name); //Jim
Wystarczy dołożyć readonly modifier, aby nie dało się modyfikować name:
class SingletonPerson {
static #instance : SingletonPerson;
public readonly name: string;
private constructor(name: string){
this.name = name;
}
//(...)
}
let singPers = SingletonPerson.createInstance("John");
console.log(singPers.name); //John
let singPersAgain = SingletonPerson.getInstance();
console.log(singPersAgain.name); //John
singPersAgain.name = 'Jim'; //error
Chyba nie muszę dodawać, że readonly jest tylko w TS? Aby zrobić to readonly także w JS to trzeba by zrobić private przez #, getter i setter, Aby zrobić tylko w JS to defineProperty i writable na false, ale tak się można bawić w czystym JS.
Ok, zrobimy tak, aby było readonly i w TS i w JS:
class SingletonPerson {
static #instance : SingletonPerson;
public readonly name: string;
private constructor(name: string){
this.name = name;
Object.defineProperty(this, 'name', {writable: false});
}
public static getInstance(){
if(SingletonPerson.#instance)
return SingletonPerson.#instance;
return new SingletonPerson("Unknown");
}
public static createInstance(name: string){
if(!SingletonPerson.#instance){
SingletonPerson.#instance = new SingletonPerson(name);
}
return SingletonPerson.#instance;
}
}
W TS readonly zapewnia nam readonly modifier, w JS defineproperty writable na false. Dodam, że bez strict mode repl będzie nam pokazywać bez erroru nadpisanie name, natomiast nie zostanie ono zmienione.
Ok, przepraszam za badziewny kod, ale z dokumentacji weźmiemy sobie jakąś klasę abstrakcyjną, dopiszemy interfejs i kompilujemy zobaczyć co z tego zostanie:
interface Interface100 {
display: () => void;
find: (string) => Interface100;
}
abstract class Person100 implements Interface100 {
name: string;
constructor(name: string) {
this.name = name;
}
display(): void{
console.log(this.name);
}
abstract find(string): Person100;
}
Oto, co dostajemy:
class Person100 {
constructor(name) {
this.name = name;
}
display() {
console.log(this.name);
}
}
Czyli JS na interfejsy jest totalnie ślepy, zaś klasy abstrakcyjne mają tylko nieabstrakcyjne pola i metody. Ma to sens, w końcu będziemy w TS z tych klas dziedziczyć i jeśli one mają jakieś konkretne metody to po kompilacji musi wyjść klasa, która dziedziczy z klasy abstrakcyjnej te metody.
Ok, ta lekcja robi się długa, więc zrobimy jeszcze jedną, generyczną klasę collection, aby pokazać kilka rzeczy:
class Collection<T> {
constructor(private _data: T[]) {
this._data = _data;
}
//(...)
}
To jest constructor promotion, raczej pamietamy z PHP. Private robi prywatność tylko w TS, zaś # robiłoby i w TS i w JS, pamiętamy. Dobra, getter dla length:
class Collection<T> {
constructor(private _data: T[]) {
this._data = _data;
}
get length(): number {
return this._data.length;
}
}
To zwykły getter, nic więcej. A teraz dodamy coś, co getterem nie jest, tylko metodą o nazwie get:
class Collection<T> {
constructor(private _data: T[]) {
this._data = _data;
}
get length(): number {
return this._data.length;
}
get(key: number): T | -1 {
if(key >= this.length)
return -1;
return this._data[key];
}
//(...)
}
Dodam, że to nie są jakieś super kody, po prostu chcę pokazać składnię różnych rzeczy. Tutaj mamy taki return type, który zwraca albo T albo -1 (literalnie -1 i nic innego), tak się w TS można bawić.
Ok, teraz jeszcze getter i setter dla _data oraz push:
class Collection<T> {
constructor(private _data: T[]) {
this._data = _data;
}
get length(): number {
return this._data.length;
}
get(key: number): T | -1 {
if(key >= this.length)
return -1;
return this._data[key];
}
get data() : T[]{
return this._data;
}
set data(newData: T[]) {
this._data = newData;
}
push(val: T) : void {
this._data.push(val);
}
}
Jest tak, że data zwraca tablicę typu T. Setter musi też taką tablicę przyjmować, natomiast setterowi nie można robić anotacji typu, nawet void nie może być. Push zaś może mieć anotację void, bo to nie jest setter. Zaś val musi być typu T, inaczej popsułoby to _data.
Poprzednia lekcja była trudna, ta jest raczej upierdliwa. Wyjaśniamy sobie takie denerwujące, często mylące się ze sobą rzeczy, żaden rocket science, ale też są to w moim odczuciu rzeczy dość upierdliwe.
Ok, to może jeszcze przykładzik z jakichś docsów (nawet nie oficjalnych) odnośnie extends:
class Employee {
public empName: string;
protected empCode: number;
constructor(name: string, code: number){
this.empName = name;
this.empCode = code;
}
}
class SalesEmployee extends Employee{
private department: string;
constructor(name: string, code: number, department: string) {
super(name, code);
this.department = department;
}
}
let emp = new SalesEmployee("John Smith", 123, "Sales");
emp.empCode; //Compiler Error
Czyli tak, implements służy do interfejsów, extends ma w TS dwa użycia, pierwszy to przy typowaniu (już to robiliśmy w generic constraints oraz innym tworzeniu typów), drugi to tak jak w JS, dziedziczenie z klasy.
Jakbyśmy chcieli pisać klasy, które mają metody, których używamy jako event listener to pamietajmy o używaniu bind, inaczej nie mamy dostępu do kontekstu klasy.
W epizodzie o dekoratorach poznaliśmy dekorator autobind, bardzo pomocny (w tej wersji, inne mogą powodować memory leaki oraz niemożność użycia removeEventListener):
const IDENTIFIER = `@typed-decorators/autobind`;
export function autobind<F extends (...args: Array<any>) => any>(
_target: any,
name: string,
descriptor: TypedPropertyDescriptor<F>,
): TypedPropertyDescriptor<F> {
const { enumerable, configurable, value } = descriptor;
const boundMethod = Symbol(`${IDENTIFIER}/${name}`);
return {
enumerable,
configurable,
get(this: { [boundMethod]: any }) {
return this[boundMethod] || (this[boundMethod] = value!.bind(this));
},
set(value: F) {
Object.defineProperty(this, name, {
writable: true,
enumerable: true,
configurable: true,
value,
});
},
};
}
Niedługo poznamy utility types w TS oraz dekoratory w nowej wersji, bo coś tam się zmieniło (mam nadzieję, że na lepsze). Potem jeszcze dosłownie dwa-trzy tematy z bardziej zaawansowanym typowaniem i można siadać do pisania TSów w node/react albo pomyśleć nad wykończeniem biblioteczki DOM.
Do następnego razu!