Static Type Checking of Classes
Classes were introduced to JavaScript ecosystem by ES5.
Instance Members
Instance Properties
A class in JavaScript can have instance properties. They can be declared with or without initializers.
After the class initialization, the instance properties become properties of the object created through initialization.
class Foo {
bar = 42
baz
}
const qux = new Foo()
qux.bar // => 42
qux.baz // => undefined
TypeScript allows for restricting types of instance properties.
class Foo {
bar: number = 42
baz: string
}
const qux = new Foo()
qux.bar = 23 // OK
qux.bar = '23' // Type 'string' is not assignable to type 'number'.
qux.baz = '42' // OK
qux.baz = 42 // Type 'number' is not assignable to type 'string'.
If no type is annotated and no initializer is present at an instance property it has the implicit any type. If an initializer is present TypeScript will attempt type inference.
It is possible to enforce property initialization in constructor through setting the flag strictPropertyInitialization
in tsconfig.json
to true
.
Instance Method Signatures
When an instance property's value is a function then the property is called an instance method.
Just like with non-method functions TypeScript allows for annotating instance method parameter types and return types.
class Student {
firstName = 'Joe'
changeFirstName(newFirstName: string): string {
this.firstName = newFirstName
return firstName
}
}
const student = new Student()
student.changeFirstName('Other Joe') // => Student {firstName: 'Other Joe'}
student.changeFirstName(42) // TS: Argument of type 'number' is not assignable to parameter of type 'string'.
This in Instance Methods
Within a body of an instance method, the this
keyword might but does not have to reference an instance of the class within the body of which the method was defined.
Generally, the value referenced by this
in a JavaScript non-arrow function does not depend on how the function was defined but on how it is called. For extended reference see soundof.it JavaScript Tutorial - This.
class Captain {
name = 'Hook'
ship = 'Jolly Roger'
logName() {
console.log(this.name)
}
}
const captain = new Captain()
const student = {
name: 'Mark',
logFavoriteCaptain: captain.logName
}
student.logFavoriteCaptain() // Mark
The probable intention of the above code was to log the student favorite captain's name. Instead, the name of the student was logged. TypeScript can catch such unintended operations through type annotation of this
in instance methods.
class Captain {
name = 'Hook'
ship = 'Jolly Roger'
logName(this: Captain) {
console.log(this.name)
}
}
const captain = new Captain()
const student = {
name: 'Mark',
logFavoriteCaptain: captain.logName
}
student.logFavoriteCaptain()
// The 'this' context of type '{ name: string; logFavoriteCaptain: (this: Captain) => void; }' is not assignable to method's 'this' of type 'Captain'.
// Type '{ name: string; logFavoriteCaptain: (this: Captain) => void; }' is missing the following properties from type 'Captain': ship, logName.
However, it needs to be remembered that TypeScript checks the compatibility of types structurally. Therefore, TypeScript will not find any problem with the following example.
class Captain {
name = 'Hook'
logName(this: Captain) {
console.log(this.name)
}
}
const captain = new Captain()
const student = {
name: 'Mark',
logName: captain.logName
}
student.logName() // Mark
The initial parameter named this
in an instance method (or any other function) references the type of JavaScript this
and is erased during compilation.
A thing to remember is that an arrow instance method behaves differently when it comes to this
to the non-arrow instance method.
class Captain {
name = 'Hook'
logName = () => console.log(this.name)
}
const captain = new Captain()
const student = {
name: 'Mark',
logName: captain.logName
}
student.logName() // Hook
In other words, the value referenced by this
within an arrow function does depend on how it was defined and not on how it is called.
Instance Property Accessibility
When it comes to accessibility an ES5 class can have two kinds of instance properties:
-
public - defined or declared without any prefix, and
-
private - defined or declared with
#
prefix.
At runtime, public properties are freely accessible. On the other hand, private properties are only accessible within the class in which they are declared.
class Foo {
bar = 42
#baz = 23
}
const qux = new Foo()
qux.bar // => 42
qux.baz // => undefined
qux.#baz // Uncaught SyntaxError: Private field '#baz' must be declared in an enclosing class
class Baz extends Foo {
logBaz() {
console.log(this.#baz) // Property '#baz' is not accessible outside class 'Foo' because it has a private identifier.
}
}
Private properties defined or declared with #
are ES5 - not TypeScript - specific feature.
TypeScript features its pre-compilation property modifiers when it comes to accessibility:
-
public
-
soft protected, and
-
soft private.
Public properties are denoted with no modifiers or by the keyword public
and accessible without limitations.
Protected properties are denoted by the modifier protected
and are accessible only within the class within which they were declared and within its subclasses.
Private properties are denoted by the modifier private
and when using the dot notation are accessible only within the class within which they were declared.
The protected and private properties in TypeScript are called soft protected and soft private because it is still possible to access them using the bracket notation.
class Captain {
public name = 'Hook'
protected ship = 'Jolly Roger'
private nemesis = 'Peter Pan'
}
const captain = new Captain()
console.log(captain.name) // 'Hook'
console.log(captain.ship) // Property 'ship' is protected and only accessible within class 'Captain' and its subclasses.
console.log(captain.nemesis) // Property 'nemesis' is private and only accessible within class 'Captain'.
console.log(captain['ship']) // 'Jolly Roger'
console.log(captain['nemesis']) // 'Peter Pan'
class starshipCaptain extends Captain {
name = 'Jean Luc'
ship = 'Enterprise'
nemesis = 'Q'
}
// Class 'starshipCaptain' incorrectly extends base class 'Captain'.
// Property 'nemesis' is private in type 'Captain' but not in type 'starshipCaptain'.
In the above example the class starshipCaptain
attempts to make the ship
and nemesis
properties public. It succeeds with regards to the ship
property (as it is accessible to it) but fails with regards to the nemesis
property (as it is inaccessible to it).
One other thing to remember about TypeScript private properties is that they are cross-instance accessible (similarly to Java, C# and PHP) as opposed to private properties in Ruby.
Constructors
Class constructors in JavaScript are functions that are being called at class instance initialization. During the call the this
keyword in the constructor denotes the instance being created. It allows for initialization of not yet initialized properties and/or other operations.
A constructor always implicitly returns the instance and using the keyword return
within its body is not allowed.
// ES5
class Student {
firstName
lastName
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
}
const student = new Student('Ciri', 'Riannon')
student // => Student {firstName: 'Ciri', lastName: 'Riannon'}
student.firstName // => 'Ciri'
student.lastName // => 'Riannon'
Just like with regular functions TypeScript allows for restricting types of constructor parameters.
// TypeScript
class Student {
firstName: string
lastName: string
constructor(firstName: string, lastName: string) {
this.firstName = firstName
this.lastName = lastName
}
}
const student = new Student(42, 42) // TS: Argument of type 'number' is not assignable to parameter of type 'string'.
Static Members
A class in JavaScript can have static members i.e. members that are not associated with and specific to instances of the class but with the class object itself.
class Captain {
name: string
static captainNames: string[] = []
constructor(name: string) {
this.name = name
Captain.captainNames.push(name)
}
}
const hook = new Captain('Hook')
const blackbeard = new Captain('Blackbeard')
const janeway = new Captain('Janeway')
console.log(Captain.captainNames) // (3) ['Hook', 'Blackbeard', 'Janeway']
Static class members are inherited.
Static class members can be made private at runtime using the #
modifier. Further, TypeScript can alter their pre-runtime accessibility with public
, protected
and private
modifiers.
Checking Implementation
TypeScript allows for static checking of congruency of class instance members minimal implementation with a specific contract outlined by an interface or a type alias.
To check whether a given class correctly implements an interface or a type alias the keyword implements
is used appended with the interface name or type alias.
interface Captainable {
name: string
ship: string
}
class Captain implements Captainable {
name: string
ship: string
nemesis: string // Additional property not present in the interface which is OK.
} // OK
class Student implements Captainable {
firstName: string
lastName: string
major: string
}
// TS: Class 'Student' incorrectly implements interface 'Captainable'.
// TS: Type 'Student' is missing the following properties from type 'Captainable': name, ship.
In the above example the class Captain
meets the interface Captainable
contract as it has all the required properties with the specified types whereas the class Student
does not.
A congruency with the implementation of more than one interface or type alias simultaneously is possible to be checked.
interface Nameable {
name: string
}
interface Shipable {
ship: string
}
class Captain implements Nameable, Shipable {
name: string
ship: string
} // OK
The implementation check with the implements
keyword in no way supersedes or mimics the class extension using the keyword extends
!