N4JS and TypeScript
N4JS and TypeScript are both supersets of ECMAScript. They both introduce type annotations and a static type checker. However, in their relation to JavaScript, they follow different approaches.
TypeScript tries to make ECMAScript type-safe without invalidating existing ECMAScript code. Its type system is optional and the TypeScript transpiler aims to accept plain ECMAScript wherever as possible.
Although N4JS is a superset of ECMAScript in terms of syntax and features, it does not try to be compatible with ECMAScript at all cost. One way of looking at the N4JS approach is to begin with ECMAScript, add Java’s strict and rigorous type system and then to make this amalgamation as flexible as possible. The idea is that this fulfills the expectations of JavaScript programmers while keeping the type system sound.
Differences
In many cases TypeScript’s design prioritizes the transition from ECMAScript to TypeScript over type safety. N4JS was designed with ease of transition in mind, but type safety has a higher priority than the ease of transition.
Any
Both languages introduce a type called any
.
However, the precise meaning of any
is probably the most important difference between N4JS and TypeScript.
Simply put:
In N4JS you can do nothing with any
, in TypeScript you can do anything.
The following example illustrates the difference:
function f(p: any) {
p.foo(); // error in N4JS, no error in TypeScript
}
N4JS will issue an error: Couldn’t resolve reference to IdentifiableElement 'foo'
, because in N4JS, the type any
has no properties.
Furthermore, in N4JS any
is the top type: every type is a subtype of any
. In TypeScript it is treated as a bottom
type similar to undefined
(or null
): any
is a subtype of every other type. The effect of these different semantics is shown in the following example:
function bar(p: string) {
p.charAt(0);
}
var s: string = "Hello";
var x: any = 42;
bar(s);
bar(x); // error in N4JS, no error in TypeScript
Of course, you would get an error at runtime: TypeError: p.charAt is not a function
The differing interpretations of any
reflect the different approaches visualized in the figure at the beginning.
any
in TypeScript is JavaScript in pure form: access anything, assign to everything. any
in N4JS is even more rigorous than type Object
in Java: access nothing, assign to nothing (except any
itself).
N4JS allows developers to use types in dynamic way, by using the +
type modifier.
This so-called dynamic type modifier allows for accessing arbitrary properties even when they are not known to the type system. The following example shows the effect:
function f(p: any, d: any+) {
p.foo(); // error in N4JS
d.foo(); // no error in N4JS, as `d` is "dynamic"
}
While any+
resembles TypeScript’s behavior of any
, it is still more restrictive: any+
will never be used as a default, it has to be declared explicitly; and a value of type any+
still cannot be assigned to variables of other types except any
.
access anything |
assign to everything |
used as default |
||
---|---|---|---|---|
N4JS |
|
no |
no |
• |
|
yes |
no |
||
TypeScript |
|
yes |
yes |
• [1] |
TypeScript 3 introduced the unknown
type which behaves like N4JS’s any
type, with the exception that it isn’t currently being used by default and has to be declared explicitly.
Type Errors Are Show-Stoppers in N4JS
N4JS has two general levels of issues reported by the compiler: warning and error. Serious issues like type errors are treated as errors in N4JS and all errors will prevent the transpiler to emit any JavaScript code in order to avoid producing code that might cause exceptions at runtime. For TypeScript, on the other hand, it is a main concern to never impede the developer, the transpiler will thus produce JavaScript output code even in the case of compile errors. Given the example from the beginning
var str = 'Hello';
str = 42; (1)
str.charAt(2);
1 | Both N4JS and TypeScript show an error here. |
The N4JS transpiler will reject the compilation of that code, while TypeScript will create a JavaScript output file that causes exceptions at runtime in the last line.
Parameter Contra-variance vs. Bivariance
In N4JS, when overriding a method in a subtype, the types of the parameters may be super types, and the return type may be a sub type. Compare this to Java, where the return type may also be a sub type, but the parameter types must be the same. Given the following classes
class A { fa() {} }
class B extends A { fb() {} }
class C extends B { fc() {} }
and the following super type
class Sup {
f(b: B): B { return new B() }
}
a sub type of Sup
may override f
as follows:
class Sub extends Sup {
@Override
f(a: A): C { return new C() }
}
In type theory, this is expressed with co- and contra-variance: Given a type Sup
with a method M
, and a subtype of Sub
with a method N
overriding M
(that is, M
and N
have the same names), then:
-
the parameter types of overriding methods need to be contra-variant - the type of the parameters of
N
need to be super types [2] of the types of the parameters ofM
-
the return type of overriding methods need to be co-variant, that is the type of the parameters of
N
need to be a sub type of the return type ofM
The same is true when checking assignability for function types, e.g.
f(callback: (B)=>B) {}
can be called with
f((a: A) => return new C())
In Typescript, the parameter types may be contra- or covariant, that is bivariant (see Handbook and TypeScript Spec, Assignment Compatibility and Inheritance and Overriding).
This is unsound, as already stated in the TypeScript (Handbook):
This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type.
In the context of function objects (as in the example with the callback parameter) this may be quite convenient. And for that very special use case, we agree with the TS handbook:
In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns.
However, in the context of overriding methods and generics, this leads to severe problems, which are probably not that "rare".
Violated Substitution Principle
This assumed bivariance actually violates the so called subsitution principle. In TypeScript, the following code is accepted without errors or warnings:
class TSSub extends Sup {
f(b: C): B { b.fc(); return new B() }
}
The following function uses the super class Sup
and assumes that its method f
accepts a parameter of type B
.
function g(s: Sup) {
let b = s.f(new B());
}
The substitution principles states that we can use a subclass instead of the super class. However, this is not true in case of TypeScript anymore. The following code will create a runtime error:
f(new TSSub());
This will be surprising for the programmer of that call, but also for the developer of function g
.
Use-Site Variance vs. Assumed Co-Variance
Parameter bivariance seems to solve some variance problems in the context of generics. Let’s have a look at the hello-world example for generics, a simplified list that can hold only a single element:
class List<T> {
read(): T { /* .. */ }
write(T) { /* .. */ }
}
and two variables
let la: List<A>(), lb: List<B>;
Programmers familiar with Java or Scala know that it often causes headaches when using generics and assigning instances of generics. Take the following assignments for example:
la = lb; (1)
lb = la; (2)
1 | This works in TypeScript. N4JS (and Java) issue an error |
2 | Both TypeScript and N4JS (and Java) issue an error |
On first glance, it looks great that TypeScript does not issue any errors here. Since it’s not obvious why both assignments are rejected by N4JS, let’s have a look at what happens next:
la = new List<A>(); la.write(a); lb = la; lb.read().fb();
TypeScript would issue no errors, but we would get a runtime error in the last call:
since the list does not contain an instance of B
, the method is undefined.
The same error occurs in the following case:
lb = new List<B>(); la = lb; la.write(a); lb.read().fb());
This is true because List<T>
is invariant (that it is neither co- nor contra-variant):
* List is not co-variant: Even if B
is a subtype of A
, List<B>
is not a subtype of List<A>
* List is not contra-variant: Even if B
is a subtype of A
, List<B>
is not a supertype of List<A>
In practice, this is very inconvenient.
It would be O.K. to use lb
instead of la
assuming we only want to read from the list.
On the other hand, if we only want to write to the list then we could use la
instead of lb
since adding B
s to a list expecting A
does not do any harm.
There are different solutions to the same problem.
Java uses use-site variance, and this is also supported by N4JS. When the list is used, we can define whether we want to read or write from it. This can be done by using so-called 'wildcards' and constraints when parameterizing the list, for example:
function copy(readOnlyList: List<? extends A>, writeOnlyList: List<? super A>) {
writeOnlyList.write( readOnlyList.read() );
}
Scala uses def-site variance, which is also supported by N4JS. In that case, you define at the definition of a generic type that a type variable is only used for read or write. E.g.,
interface ReadOnlyList<out T> {
read(): T
}
interface WriteOnlyList<in T> {
write(T): void
}
class List <T> implements ReadOnlyList<T>, WriteOnlyList<T> {
@Override
read(): T { /* .. */ return null;}
@Override
write(T) { /* .. */ }
}
function copy(readOnlyList: ReadOnlyList<A>, writeOnlyList: WriteOnlyList<A>) {
writeOnlyList.write( readOnlyList.read() );
}
For more information on generics, please refer to the generics feature page.
Similarities
Explicit and Implicit typing
In both languages, types can either be defined explicitly (via a type annotation) or implicitly. In the latter case, the type is to be inferred by the type system. A simple example is the assignment of a value to a newly declared variable, such as
let foo = "Hello";
Both languages would infer the type of foo
to string
.
In both languages the following assignment would, therefore, lead to an error:
foo = 42; // error
-
N4JS would issue
int is not a subtype of string.
, -
TypeScript would issue
Type
number
is not assignable to typestring
Structural Types
N4JS and TypeScript both support structural types.
This allows for managing relations between types without the need for excessive declarations.
Instead of explicitly defining type relations via extends
or implements
, the type system compares the properties of two types.
If one type has all the properties of another type, it is considered to be a subtype.
As a significant difference between the two languages, N4JS also supports nominal types and nominal typing is the default.
Thus, structural types have to be explicitly annotated as being structural, using the ~
or ~~
type constructors.
N4JS | JavaScript |
---|---|
|
|
N4JS is using different defaults for access modifiers, e.g. public is not the default. For that reason, the interfaces have to be marked as public (and exported).
|
In both languages, an error will be issued on the last line:
- N4JS
-
Point is not a structural subtype of Point3D: missing field z.
- Typescript
-
Type 'Point' is not assignable to Type 'Point3D'. Property 'z' is missing in type 'Point'.
The difference between structural and nominal typing is described in further detail in the nominal vs. structural subtyping feature.
Using Existing JavaScript Libraries
An important aspect of being an ECMAScript superset is to enable developers to use existing JavaScript libraries. N4JS and TypeScript support type definitions for existing code. For TypeScript, there is a great project called DefinitelyTyped where type definitions are collected. For N4JS, a similar GitHub project exists, but it contains very few definitions at the moment. Contributions are welcome for both projects.
It is also possible to use existing code in both languages without type definitions, Common.js modules in particular. The N4JS IDE integrates support for NPM, so that these modules, even without a type definition, can seamlessly be used in N4JS.