TypeScript$Type-Value-Class

117 阅读10分钟

TypeScript$Type-Value-Class

JavaScript 的 classfunction 用来构建“类”时的语法糖,它让 JavaScript 更像其他的面向对象语言,但又不完全像:

  • 比如在 Java 中,构建器是类名,而在 JavaScript 中是固定的 constructor
  • Java 等语言可以实现接口,但 JavaScript 中没有接口的概念
  • Java 中存在 public & protected & private 等“可见性约束”标识;JavaScript 中属性默认是 public# 代表 private,不存在 protected

TypeScript 所做的就是让 JavaScript 的 class 更像其他语言,并且加上了类型。

1. Class Members

class Point {
// 1. fields
  x: number;
  y = 0;
  // 1.1 readonly, can change in constructor
  readonly age:number = 18;
  // 2. constructors
  // Constructors can’t have type parameters - these belong on the outer class declaration
  // Constructor overloads
  constructor(x: number, y: number);
  constructor(xy: string);
  constructor(x: string | number, y: number = 0) {
    // Code logic here
  }

  // 3. methods
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
  // 4. getter and setter: nothing usual
  // 5. index signatures: nothing usual
}
 
const pt = new Point();
pt.x = 0;

The strictPropertyInitialization setting controls whether class fields need to be initialized in the constructor. 如果没有初始化,则需要在 constructor 中设置。在构造器中调用方法初始化不视作初始化。但如果想这么做,则需要使用 !definite assignment assertion operator

class GoodGreeter {
  name: string;
 
  constructor() {
    this.name = "hello";
  }
}
class OKGreeter {
  // Not initialized, but no error
  name!: string;
}

2. Class Heritage

2.1 class C implements interfaceA, interfaceB {

interface Checkable {
  check(name: string): boolean;
}
 
class NameChecker implements Checkable {
  check(s) {
// error, Parameter 's' implicitly has an 'any' type.
    // Notice no error here
    return s.toLowerCase() === "ok";
                 // any
  }
}
interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;
// error, Property 'y' does not exist on type 'C'.

2.2 class C extends classB {

overriding methods 重写方法:需要保持类型一致。(因为子类也“是”父类)

Type-only Field Declarations

如果想在子类中重新声明一个 field 的类型,可以使用 declare 声明。 When target >= ES2022 or useDefineForClassFields is true, class fields are initialized after the parent class constructor completes, overwriting any value set by the parent class. This can be a problem when you only want to re-declare a more accurate type for an inherited field. To handle these cases, you can write declare to indicate to TypeScript that there should be no runtime effect for this field declaration.

interface Animal {
  dateOfBirth: any;
}
 
interface Dog extends Animal {
  breed: any;
}
 
class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}
 
class DogHouse extends AnimalHouse {
  // Does not emit JavaScript code,
  // only ensures the types are correct
  declare resident: Dog;
  constructor(dog: Dog) {
    super(dog);
  }
}

Initialization Order

和官方文档 Initialization Order 说的顺序不同,在 PlayGround 里,编译后的 JavaScript 的代码里,fields 初始化被移到了 constructor 中,而且是当前类的 constructor 先执行。注意下面的 fields 的初始化:父类是先初始化值,后执行 constructor;子类是 super 后再进行初始化 fields(因为得 super 完了后才是使用 this。这也让官网的描述有些矛盾(第三步应该在第四步里))。

The order of class initialization, as defined by JavaScript, is:

  • The base class fields are initialized
  • The base class constructor runs
  • The derived class fields are initialized
  • The derived class constructor runs
class Base {
  name = "base";
  constructor() {
    console.log('1-1')
    console.log("My name is " + this.name);
    console.log('1-2')
  }
}

class Derived extends Base {
  name = "derived";
  constructor() {
    console.log('2-1')
    super()
    console.log('2-2')
  }
}

// Prints "base", not "derived"
const d = new Derived();
"use strict";
class Base {
    constructor() {
        this.name = "base";
        console.log('1-1');
        console.log("My name is " + this.name);
        console.log('1-2');
    }
}
class Derived extends Base {
    constructor() {
        console.log('2-1');
        super();
        this.name = "derived";
        console.log('2-2');
    }
}
// Prints "base", not "derived"
const d = new Derived();
[LOG]: "2-1" 
[LOG]: "1-1" 
[LOG]: "My name is base" 
[LOG]: "1-2" 
[LOG]: "2-2" 

override

如果想明确表示子类重写父类的方法,可以使用 override

如果配置文件中 "noImplicitOverride": true,则确保重写时必须有 override 前缀。

class A {
  say() {
    console.log(1)
  }
}

class B extends A {
  override say() {
    console.log(2)
  }
}

3. Member Visibility

1. public

就像没写一样。写上是为了强调。

2. protected

protected members are only visible to subclasses of the class they’re declared in.

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}
 
class SpecialGreeter extends Greeter {
  public howdy() {
    // OK to access protected member here
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName();
// error, Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.

Exposure of protected members

Derived classes need to follow their base class contracts, but may choose to expose a subtype of base class with more capabilities. This includes making protected members public:

class Base {
  protected m = 10;
}
class Derived extends Base {
  // No modifier, so default is 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

Cross-hierarchy protected access

Different OOP languages disagree about whether it’s legal to access a protected member through a base class reference:

class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Derived1) { // Base also not working
    other.x = 10;
// error, Property 'x' is protected and only accessible within class 'Derived1' and its subclasses.
  }
}

Java, for example, considers this to be legal. On the other hand, C# and C++ chose that this code should be illegal.

TypeScript sides with C# and C++ here, because accessing x in Derived2 should only be legal from Derived2’s subclasses, and Derived1 isn’t one of them. Moreover, if accessing x through a Derived1 reference is illegal (which it certainly should be!), then accessing it through a base class reference should never improve the situation.

我对上面的解释有些疑问。更大的疑问是为什么 f1 中可以获取到 x。我的想法是不应该通过实例获取 protected 的属性值(就像上面 Moreover 那里说的 reference)。所以为什么 f1 可以获取呢?只是因为 f1 是在 class Derived2 中定义的,所以就可以通过实例来访问,而不是通过 other 中的 public 方法?我不是很理解。 或者说我的“不能基于实例来获取 protected 属性”的这个假设在这里不适用了。

就结果而言。因为 f1 在 class Derived2 中,而 other 是 Derived2,所以可以 other.x = 10。因为 f2 在 class Derived2 中,而不是在 class Derived1 中,other 是 Derived1,所以不可以 other.x = 10。即使 f2 中入参类型是 Base 也不行。

3. private

private 只允许在 class 内使用,子类无法访问父类的 private 属性,且不能覆盖(无论可见性)。

class Base {
  private x = 0;
}
const b = new Base();
// Can't access from outside the class
console.log(b.x);
// error, Property 'x' is private and only accessible within class 'Base'.
class Derived extends Base {
  showX() {
    // Can't access in subclasses
    console.log(this.x);
// error,Property 'x' is private and only accessible within class 'Base'.
  }
}
class Base {
  private x = 0;
}
class Derived extends Base {
/* error, Class 'Derived' incorrectly extends base class 'Base'.
  Property 'x' is private in type 'Base' but not in type 'Derived'. */
  x = 1;
}

Cross-instance private access

Different OOP languages disagree about whether different instances of the same class may access each others’ private members. While languages like Java, C#, C++, Swift, and PHP allow this, Ruby does not.

class A {
  private x = 10;
 
  public sameAs(other: A) {
    // No error
    return other.x === this.x;
  }
}

Caveats

private also allows access using bracket notation during type checking.

class MySafe {
  private secretKey = 12345;
}
 
const s = new MySafe();
 
// Not allowed during type checking
console.log(s.secretKey);
// error, Property 'secretKey' is private and only accessible within class 'MySafe'.
 
// OK
console.log(s["secretKey"]);

4. # Real Private - Private field presence checks

需要注意的是,# 只是不能更改,通过调试工具任然能够看到。所以别存敏感数据。

注意下方的 equals。因为所谓的“私有”指的是同类 class 中的类型,所以只有同类才能获取到 #specialNumber 属性。

class Car {
  static #nextSerialNumber: number
  static #generateSerialNumber() { return this.#nextSerialNumber++ }
 
  make: string
  model: string
  year: number
  #serialNumber = Car.#generateSerialNumber()
 
  constructor(make: string, model: string, year: number) {
    this.make = make
    this.model = model
    this.year = year
  }
  equals(other: unknown) {
    if (other &&
      typeof other === 'object' &&
      #serialNumber in other) { // other: (parameter) other: Car
        return other.#serialNumber = this.#serialNumber
      }
      return false
  }
}
const c1 = new Car("Toyota", "Hilux", 1987)
const c2 = c1
c2.equals(c1)

4. Static Members

如果类属性前面加上了 static,则这个属性属于类本身。同样可以使用 publicprotectedprivate。子类可以继承。

Special Static Names

classfunction 的语法糖,static 的属性实际上是加到对应的 function 身上的。而所有的 function 都继承于 Function,所以 static 属性是有限制的——不能覆盖一些 Function 的既定属性。Function properties like namelength, and call aren’t valid to define as static members:

class S {
  static name = "S!";
// error, Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.
}

5. static Blocks in Classes

static Blocks 会在类加载的时候执行,可访问类中的所有内容,可以用来初始化。

6. Generic Classes

类可以声明类型。类型是给实例用的,static 属性不能够获取。

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}
 
const b = new Box("hello!");
     // const b: Box<string>
class Box<Type> {
  static defaultValue: Type;
// error. Static members cannot reference class type parameters.
}

7. this at Runtime in Classes

JavaScript 中的 this 和其他语言中的不同:它的值取决于是如何调用的(The value of this inside a function depends on how the function was called)。对于熟悉 JavaScript 的前端人员来说,这其实不是问题。但是熟悉其他语言的人说这是诡异现象,认为 this 就应该是当前实例。😐

为了配合熟悉其他语言的人,或者 “this 就应该是当前实例”这种观点,TypeScript 官方文档列出了两种解决方案。

Arrow Functions

Arrow Functions 没有 this,它们只能去外面找,结果就是 this 固定在定义它的地方(lexical scope)。

this parameters

TypeScript 增加了额外的语法允许声明 this 的类型。this 是 JavaScript 中的关键字,不允许当做变量使用。但是在 TypeScript 中,this 可以作为形参来声明类型。当编译成 JavaScript 时,this 会被抹去。

在使用的时候,TypeScript 会检查调用的 this 是不是符合其类型。

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();
 
// Error, would crash
const g = c.getName;
console.log(g());
// error. The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.

8. this Types

类型 this 主要用在类的继承。如果一个变量类型为 this,那么子类中这个类型就是子类。

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}
 
class DerivedBox extends Box {
  otherContent: string = "?";
}
 
const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
/* error. Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
  Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'. */

this-based type guards

You can use this is Type in the return position for methods in classes and interfaces. When mixed with a type narrowing (e.g. if statements) the type of the target object would be narrowed to the specified Type.

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}
 
class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}
 
class Directory extends FileSystemObject {
  children: FileSystemObject[];
}
 
interface Networked {
  host: string;
}
 
const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");
 
if (fso.isFile()) {
  fso.content;
  // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children;
  // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host;
  // const fso: Networked & FileSystemObject
}

A common use-case for a this-based type guard is to allow for lazy validation of a particular field. For example, this case removes an undefined from the value held inside box when hasValue has been verified to be true:

class Box<T> {
  value?: T;
 
  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}
 
const box = new Box<string>();
box.value = "Gameboy";
 
box.value;
     // (property) Box<string>.value?: string
 
if (box.hasValue()) {
  box.value;
       // (property) value: string
}

9. Parameter Properties

在 JavaScript 中,我们可以在 constructor 中直接设置属性 properties,而不需要设置 fields。

但是在一些 OOP 语言中,fields 用来表示属性,在 constructor 或者 methods 中设置这些 fields。

TypeScript 选择了那些 OOP 语言—— class 的 fields 是必须的。如果我们没有声明 field x,那么我们不能在 constructor 中通过 this.x = x 来设置属性。TypeScript 官方文档说它提供了简介的语法:在 constructor 中通过 parameter properties 来省略声明 fields。

TypeScript offers special syntax for turning a constructor parameter into a class property with the same name and value. These are called parameter properties and are created by prefixing a constructor argument with one of the visibility modifiers publicprivateprotected, or readonly. The resulting field gets those modifier(s):

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // No body necessary
  }
}
const a = new Params(1, 2, 3);
console.log(a.x);
             // (property) Params.x: number
console.log(a.z);
// error, Property 'z' is private and only accessible within class 'Params'.

10. Constructor Signatures

JavaScript classes are instantiated with the new operator. Given the type of a class itself, the InstanceType utility type models this operation.

这里我没看懂:为什么需要 instance 的类型,而不直接把 Point 作为类型。

class Point {
  createdAt: number;
  x: number;
  y: number
  constructor(x: number, y: number) {
    this.createdAt = Date.now()
    this.x = x;
    this.y = y;
  }
}
type PointInstance = InstanceType<typeof Point>
 
function moveRight(point: PointInstance) { // 这里直接使用 Point 也不会报错
  point.x += 5;
}
 
const point = new Point(3, 4);
moveRight(point);
point.x; // => 8

11. abstract Classes and Members

An abstract method or abstract field is one that hasn’t had an implementation provided. These members must exist inside an abstract class, which cannot be directly instantiated.

abstract class Base {
  abstract getName(): string;
 
  printName() {
    console.log("Hello, " + this.getName());
  }
}
 
const b = new Base();
// error, Cannot create an instance of an abstract class.

Abstract Construct Signatures

Sometimes you want to accept some class constructor function that produces an instance of a class which derives from some abstract class.

function greet(ctor: typeof Base) {
  const instance = new ctor();
// error, Cannot create an instance of an abstract class.
  instance.printName();
}
function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
/* error, Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
  Cannot assign an abstract constructor type to a non-abstract constructor type. */

Links

TypeScriptClass

TypeScriptOverride