Blog-Archiv

Sonntag, 22. April 2018

Getting Used to TypeScript, Part 1

TypeScript is, like its base-languages ES6 and JavaScript, a very flexible language. You can express things in many different ways. This provides great freedom for developers that write new source code, but lots of efforts for those that have to read and understand it on maintenance. In that way, TypeScript reminds me a little bit of Scala, although it (fortunately) has no operator overloading. Programming languages that allow so much freedom of expression are sure not made for common sense. Mind that common sense is one of the main goals of Scrum!

I am a Java developer. For this Blog I tried to implement the interfaces and classes Point, Dimension and Rectangle in TypeScript, separating interfaces and implementations in an OO way. Following are my findings when thinking in Java but implementing in TypeScript. Both support interfaces and classes. Both support only single inheritance in classes, but multiple inheritance in interfaces.

Type Assignments

They go after the variable or parameter name. It is not

Point origin;  // Java

In TypeScript (and all newer programming languages like Scala or Kotlin) this has become

origin: Point;  // TypeScript

So the type of the field goes after the name, separated by a colon. This applies also to functions:

function foobar(foo: string, bar: number): boolean {
    return true;
}

For standalone functions (outside any class) you need the function keyword. Inside classes you must drop it.

Members Always Need this

It was called "member" in C++, and "field" in Java. I am talking about width and height in following TypeScript class:

class DimensionImpl
{
    width: number;
    height: number;

    ....

    area(): number {
        return this.width * this.height;
    }
}

Look at the area() implementation, and how class fields are accessed. Other than in Java there is no implicit "this". You always need to write this.someField when referring to a member field, or this.someFunction() for a member method.

Fortunately the compiler will remind you when you forget it. This is why software developers like compilers: they tell you your mistakes before they happen at runtime!

Interface Fields are not Constants

Constants in TypeScript interfaces are not possible. Why would I like to have constants in interfaces? Because a method of that interface may return a constant that the caller needs to recognize, and I would like to keep such constants together with the method. Same for parameters.

Both Java and TypeScript allow to have fields in interfaces. But Java interface fields are constants, while TypeScript interface fields are variables by default, and can not be initialized in the interface.

interface Colors
{
    GREEN: string = "Green";
}

This will give you a compile error:

error TS1246: An interface property cannot have an initializer.

So then, let's see if we can have constant fields in a class:

interface Dimension
{
    width: number;
    height: number;
}

class DimensionImpl implements Dimension
{
    const width: number;  // compile error!
    const height: number;  // compile error!

    ....
}

Trying to translate this, the compiler will tell you

error TS1248: A class member cannot have the 'const' keyword.

Now you can do it the Java way:

interface Dimension
{
    getWidth(): number;
    getHeight(): number;
}

class DimensionImpl implements Dimension
{
    private width: number;
    private height: number;

    ....

    getWidth(): number {
        return this.width;
    }
    getHeight(): number {
        return this.height;
    }
}

Or you can investigate and do it how TypeScript does immutable fields:

interface Dimension
{
    readonly width: number;
    readonly height: number;
}

class DimensionImpl implements Dimension
{
    readonly width: number;
    readonly height: number;

    ....

}

Of course readonly is not intuitive when having the ES6 const keyword that is responsible for immutability.

No Real Function Overloading

Try to compile following TypeScript code:

interface Point
{
    x: number;
    y: number;
    distance(): number;
    distance(other: Point): number;
}

class PointImpl implements Point
{ 
    readonly x: number;
    readonly y: number;

    ....

    distance(): number {
        return this.distance(new PointImpl(0, 0));
    }
 
    distance(other: Point): number {
        const distanceX = this.distanceX(other);
        const distanceY = this.distanceY(other);
        return Math.sqrt(distanceX * distanceX + distanceY * distanceY);
    }

    ....  // distanceX() and distanceY() implementations
}

You will get following compile error from class PointImpl (not from interface Point):

error TS2393: Duplicate function implementation.

So is the promise of function overloading in TypeScript a hoax? Not completely. You need to provide everything in just one implementation, and forward to that via empty function signatures:

interface Point
{
    x: number;
    y: number;
    distance(): number;
    distance(other: Point): number;
}

class PointImpl implements Point
{ 
    readonly x: number;
    readonly y: number;

    ....

    distance(): number;  // you could also leave this out completely!
 
    distance(other?: Point): number {
        if ( ! other )
            other = new PointImpl(0, 0);
   
        const distanceX = this.distanceX(other);
        const distanceY = this.distanceY(other);
        return Math.sqrt(distanceX * distanceX + distanceY * distanceY);
    }

    ....  // distanceX() and distanceY() implementations
}

For sure this is a surprise for OO programmers. Mind the question mark after the parameter other. It makes the parameter nullable. This is the TypeScript way of function overloading.

TypeScript Example Implementation

So here are my TypeScript interfaces and classes for Point, Dimension and Rectangle. The interface Rectangle extends both Point and Dimension. The implementation RectangleImpl extends PointImpl and holds a Dimension delegate, because it can extend just one super-class (TypeScript single inheritance), but needs to implement Dimension. Thus it must somehow duplicate the Dimension interface.

I had all files in just one directory, and compiled them with:

tsc -t ES6 *.ts
Interfaces
Dimension.ts
export interface Dimension
{
    readonly width: number;
    readonly height: number;
    area(): number;
}
Point.ts
export interface Point
{
    readonly x: number;
    readonly y: number;
    distance(): number;
    distance(other: Point): number;
    transform(origin: Point): Point;
}
Rectangle.ts
import { Point } from "./Point.js";
import { Dimension } from "./Dimension.js";

export interface Rectangle extends Point, Dimension
{
    move(origin: Point): Rectangle;
    resize(newDimension: Dimension): Rectangle;
}
Classes
DimensionImpl.ts
import { Dimension } from "./Dimension.js";

export class DimensionImpl implements Dimension
{
    readonly width: number;
    readonly height: number;

    constructor(width: number, height: number)    {
        this.width = width;
        this.height = height;
    }
    
    area(): number    {
        return this.width * this.height;
    }
}
PointImpl.ts
import { Point } from "./Point.js";

export class PointImpl implements Point
{    
    readonly x: number;
    readonly y: number;

    constructor(x: number, y: number)    {
        this.x = x;
        this.y = y;
    }
    
    distance(other?: Point): number    {
        if ( ! other )
            other = new PointImpl(0, 0);
            
        const distanceX = this.distanceX(other);
        const distanceY = this.distanceY(other);
        return Math.sqrt(distanceX * distanceX + distanceY * distanceY);
    }
    
    transform(origin: Point): Point    {
        return new PointImpl(this.distanceX(origin), this.distanceY(origin));
    }

    private distanceX(other: Point): number    {
        return this.x - other.x;
    }
    private distanceY(other: Point): number    {
        return this.y - other.y;
    }
}
RectangleImpl.ts
import { Point } from "./Point.js";
import { PointImpl } from "./PointImpl.js";
import { Dimension } from "./Dimension.js";
import { Rectangle } from "./Rectangle.js";

export class RectangleImpl extends PointImpl implements Rectangle
{
    private readonly dimension: Dimension;

    constructor(origin: Point, dimension: Dimension)    {
        super(origin.x, origin.y);
        this.dimension = dimension;
    }
    
    // START Dimension delegates
    
    width: number = this.dimension.width;
    height: number = this.dimension.height;

    area(): number    {
        return this.dimension.area();
    }
    
    // END Dimension delegates
    
    resize(newDimension: Dimension): Rectangle    {
        return new RectangleImpl(this, newDimension);
    }
    move(newOrigin: Point): Rectangle    {
        return new RectangleImpl(newOrigin, this.dimension);
    }
}

Mind that the imports name .js files to be ES6-compatible. It would not work with .ts files. It would work with no extension at all, but this is not ES6-compatible. In other words, the TypeScript compiler, in ES6 mode, analyses the dependency tree and compiles bottom-up!

A Strange Compile Result

I just compiled these sources, this worked, but I did not test them yet. As much as I see now, the compilation goes horribly wrong sometimes. When using the compiled classes, I get a runtime (!) error

TypeError: this.dimension is undefined

from RectangleImpl.js line 6:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { PointImpl } from "./PointImpl.js";
export class RectangleImpl extends PointImpl {
    constructor(origin, dimension) {
        super(origin.x, origin.y);
        // START Dimension delegates
        this.width = this.dimension.width;
        this.height = this.dimension.height;
        this.dimension = dimension;
    }
    ....
}

The bug is obvious: this.width = this.dimension.width can not work when this.dimension = dimension is done two lines later!

This has been compiled by TypeScript from following source (snippet):

export class RectangleImpl extends PointImpl implements Rectangle
{
    private readonly dimension: Dimension;

    constructor(origin: Point, dimension: Dimension)    {
        super(origin.x, origin.y);
        this.dimension = dimension;
    }
    
    // START Dimension delegates
    
    width: number = this.dimension.width;
    height: number = this.dimension.height;
    ....
}

I hoped that I can forward x and y to dimension that way, but there seems to be no easy solution for forwarding properties to a delegate object. Will this be fixed by the TypeScript team?

Meanwhile I fixed this by storing the values (redundantly) in constructor:

export class RectangleImpl extends PointImpl implements Rectangle
{
    readonly width: number;
    readonly height: number;
    private readonly dimension: Dimension;

    constructor(origin: Point, dimension: Dimension)    {
        super(origin.x, origin.y);
        this.dimension = dimension;
        this.width = dimension.width;
        this.height = dimension.height;
    }
    ....
}

The problem is obvious: the value for width is now stored two times, once in the delegate dimension, once in RectangleImpl.

Readonly Must Be in Interface

Having the readonly keyword only on the class property is not enough to make the compiler check for write accesses. Following code will not give you a compile error due to assigning dimension.width a new value:

interface Dimension
{
    width: number;
    height: number;
}

class DimensionImpl implements Dimension
{
    readonly width: number;
    readonly height: number;

    constructor(width: number, height: number) {
        this.width = width;
        this.height = height;
    }
}

const dimension: Dimension = new DimensionImpl(1, 2);
dimension.width = 999;  // NOT a compile error!!!

You need to state readonly also in the interface:

interface Dimension
{
    readonly width: number;
    readonly height: number;
}

....

const dimension: Dimension = new DimensionImpl(1, 2);
dimension.width = 999;  // the needed compile error.

Now we get the necessary access check:

error TS2540: Cannot assign to 'width' because it is a constant or a read-only property.

Conclusion

Some techniques that look promising and are accepted by the compiler seem to cause troubles at runtime. That means unit tests are indispensable also with TypeScript, albeit of strong type checks.

Another big problem seems to be dependency management. I could not make my ES6 imports run with nodejs. It gave me SyntaxError: Unexpected token import. Installing the babel transpiler libraries did not help so far (working on it).

There are many more differences between Java and TypeScript concerning interfaces and classes, I just mentioned a few, and will continue to blog them. For example, TypeScript interfaces can determine constructors (see ClockConstructor).

Integrating a new language that provides so much freedom makes common life hard. Where are the programming languages that provide just what's needed, and thus support common sense actively?




1 Kommentar:

Tejuteju hat gesagt…

It is nice blog Thank you provide important information and I am searching for the same information to save my time AngularJS Online Training