Skip to content

sirisian/ecmascript-types

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ECMAScript Proposal: Optional Static Typing

Current status of this proposal is -1. It's in a theoretical state at the moment to better understand how types could function in Javascript and the long-term future benefits or complications they could cause to future proposals.

Rationale

With TypedArrays and classes finalized, ECMAScript is in a good place to finally discuss types again. The demand for types as a different approach to code has been so strong in the past few years that separate languages have been created to deal with the perceived shortcomings. Types won't be an easy discussion, nor an easy addition, since they touch a large amount of the language; however, they are something that needs rigorous discussion.

The types described below bring ECMAScript in line or surpasses the type systems in most languages. For developers it cleans up a lot of the syntax, as described later, for TypedArrays, SIMD, and working with number types (floats vs signed and unsigned integers). It also allows for new language features like function overloading and a clean syntax for operator overloading. For implementors, added types offer a way to better optimize the JIT when specific types are used. For languages built on top of Javascript this allows more explicit type usage and closer matching to hardware.

The explicit goal of this proposal is to not just to give developers static type checking. It's to offer information to engines to use native types and optimize callstacks and memory usage. Ideally engines could inline and optimize code paths that are fully typed offering closer to native performance.

Native/Runtime Typing vs Type Annotations aka Types as Comments

This proposal covers a native/runtime type system and associated language features. That is the types introduced are able to be used by the engine to implement new features and optimize code. Errors related to passing the wrong types throws TypeError exceptions meaning the types are validated at runtime.

A type annotation or types as comments proposal treats type syntax as comments with no impact on the behavior of the code. It's primarily used with bundlers and IDEs to run checks during development. See the Type Annotations proposal for more details.

Types Proposed

  • In Proposal Specification

Since it would be potentially years before this would be implemented this proposal includes a new keyword enum for enumerated types and the following types:

number
boolean
string
object
symbol
int.<N>

Expand for all the int shorthands.
type int8 = int.<8>;
type int16 = int.<16>;
type int32 = int.<32>;
type int64 = int.<64>;

uint.<N>

Expand for all the uint shorthands.
type uint8 = uint.<8>;
type uint16 = uint.<16>;
type uint32 = uint.<32>;
type uint64 = uint.<64>;

bigint
float16, float32, float64, float80, float128
decimal32, decimal64, decimal128
vector.<T, N>

Expand for all the SIMD shorthands.
type boolean1 = uint.<1>;
type boolean8 = vector.<boolean1, 8>;
type boolean16 = vector.<boolean1, 16>;
type boolean32 = vector.<boolean1, 32>;
type boolean64 = vector.<boolean1, 64>;

type boolean8x16 = vector.<boolean8, 16>;
type boolean16x8 = vector.<boolean16, 8>;
type boolean32x4 = vector.<boolean32, 4>;
type boolean64x2 = vector.<boolean64, 2>;
type boolean8x32 = vector.<boolean8, 32>;
type boolean16x16 = vector.<boolean16, 16>;
type boolean32x8 = vector.<boolean32, 8>;
type boolean64x4 = vector.<boolean64, 4>;
type int8x16 = vector.<int8, 16>;
type int16x8 = vector.<int16, 8>;
type int32x4 = vector.<int32, 4>;
type int64x2 = vector.<int64, 2>;
type int8x32 = vector.<int8, 32>;
type int16x16 = vector.<int16, 16>;
type int32x8 = vector.<int32, 8>;
type int64x4 = vector.<int64, 4>;
type uint8x16 = vector.<uint8, 16>;
type uint16x8 = vector.<uint16, 8>;
type uint32x4 = vector.<uint32, 4>;
type uint64x2 = vector.<uint64, 2>;
type uint8x32 = vector.<uint8, 32>;
type uint16x16 = vector.<uint16, 16>;
type uint32x8 = vector.<uint32, 8>;
type uint64x4 = vector.<uint64, 4>;
type float32x4 = vector.<float32, 4>;
type float64x2 = vector.<float64, 2>;
type float32x8 = vector.<float32, 8>;
type float64x4 = vector.<float64, 4>;

rational
complex
any

These types once imported behave like a const declaration and cannot be reassigned.

Variable Declaration With Type

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

This syntax is taken from ActionScript and other proposals over the years. It's subjectively concise, readable, and consistent throughout the proposal.

var a: Type = value;
let b: Type = value;
const c: Type = value;

typeof Operator

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

typeof's behavior is essentially unchanged. All numerical types return "number". SIMD, rational, and complex types return "object".

let a: uint8 = 0; // typeof a == "number"
let b: uint8|null = 0; // typeof b == "number"
let c: [].<uint8> = []; // typeof c == "object"
let d: (uint8) => uint8 = x => x * x; // typeof d == "function"

TODO: Should there be a way to get the specific type? See #60

instanceof Operator

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

THIS SECTION IS A WIP

if (a instanceof uint8) {}

Also this would be nice for function signatures.

if (a instanceof (uint8) => uint8) {}

That would imply Object.getPrototypeOf(a) === ((uint8):uint8).prototype.

I'm not well versed on if this makes sense though, but it would be like each typed function has a prototype defined by the signature.

Union and Nullable Types

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

All types except any are non-nullable. The syntax below creates a nullable uint8 typed variable:

let a: uint8 | null = null;

A union type can be defined like:

let a: uint8 | string = 'a';

The | can placed at the beginning when defining a union across multiple lines.

type a =
  | b
  | c;

Intersection types

// TODO

any Type

  • In Proposal Specification
  • Proposal Specification Algorithms

Using any|null would result in a syntax error since any already includes nullable types. As would using [].<any> since it already includes array types. Using just [] would be the type for arrays that can contain anything. For example:

let a:[];

Variable-length Typed Arrays

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

A generic syntax .<T> is used to type array elements.

let a: [].<uint8>; // []
a.push(0); // [0]
let b: [].<uint8> = [0, 1, 2, 3];
let c: [].<uint8> | null; // null
let d: [].<uint8 | null> = [0, null]; // Not sequential memory
let e: [].<uint8 | null>|null; // null // Not sequential memory

The index operator doesn't perform casting just to be clear so array objects even when typed still behave like objects.

let a: [].<uint8> = [0, 1, 2, 3];
a['a'] = 0;
'a' in a; // true
delete a['a'];

Fixed-length Typed Arrays

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms
let a: [4].<uint8>; // [0, 0, 0, 0]
// a.push(0); TypeError: a is fixed-length
// a.pop(); TypeError: a is fixed-length
a[0] = 1; // valid
// a[a.length] = 2; Out of range
let b: [4].<uint8> = [0, 1, 2, 3];
let c: [4].<uint8> | null; // null

Typed arrays would be zero-ed at creation. That is the allocated memory would be set to all zeroes.

Also all fixed-length typed arrays use a SharedArrayBuffer by default.

Mixing Variable-length and Fixed-length Arrays

function f(c:boolean):[].<uint8> { // default case, return a resizable array
  let a: [4].<uint8> = [0, 1, 2, 3];
  let b: [6].<uint8> = [0, 1, 2, 3, 4, 5];
  return c ? a : b;
}

function f(c:boolean):[6].<uint8> { // Resizes a if c is true
  let a: [4].<uint8> = [0, 1, 2, 3];
  let b: [6].<uint8> = [0, 1, 2, 3, 4, 5];
  return c ? a : b;
}

Any Typed Array

  • In Proposal Specification
  • Proposal Specification Grammar
let a: []; // Using [].<any> is a syntax error as explained before
let b: [] | null; // null

Deleting a typed array element results in a type error:

const a: [].<uint8> = [0, 1, 2, 3];
// delete a[0]; TypeError: a is fixed-length

Array length Type And Operations

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Valid types for defining the length of an array are int8, int16, int32, int64, uint8, uint16, uint32, and uint64.

[].<T, Length = uint32>

Syntax uses the second parameter for the generic:

let a: [].<uint8, int8>  = [0, 1, 2, 3, 4];
let b = a.length; // length is type int8
let a: [5].<uint8, uint64> = [0, 1, 2, 3, 4];
let b = a.length; // length is type uint64 with value 5
let n = 5;
let a: [n].<uint8, uint64> = [0, 1, 2, 3, 4];
let b = a.length; // length is type uint64 with value 5

Setting the length reallocates the array truncating when applicable.

let a: [].<uint8> = [0, 1, 2, 3, 4];
a.length = 4; // [0, 1, 2, 4]
a.length = 6; // [0, 1, 2, 4, 0, 0]
let a:[5].<uint8> = [0, 1, 2, 3, 4];
// a.length = 4; TypeError: a is fixed-length

Array Views

Like TypedArray views, this array syntax allows any array, even arrays of typed objects to be viewed as different objects.

let view = [].<Type>(buffer [, byteOffset [, byteElementLength]]);
let a: [].<uint64> = [1];
let b = [].<uint32>(a, 0, 8);

By default byteElementLength is the size of the array's type. So [].<uint32>(...) would be 4 bytes. The byteElementLength can be less than or greater than the actual size of the type. For example (refer to the Class section):

class A {
  a:uint8;
  b:uint16;
  constructor(value) {
    this.b = value;
  }
}
const a:[].<A> = [0, 1, 2];
const b = [].<uint16>(a, 1, 3); // Offset of 1 byte into the array and 3 byte length per element
b[2]; // 2

Multidimensional and Jagged Array Support Via User-defined Index Operators

  • In Proposal Specification
  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Rather than defining index functions for various multidimensional and jagged array implementations the user is given the ability to define their own. More than one can be defined as long as they have unique signatures.

An example of a user-defined index to access a 16 element grid with (x, y) coordinates:

class GridArray<N: uint32> extends [N].<uint8> {
  get operator[](x: uint32, y: uint32) {
    return ref this[y * 4 + x];
  }
}
const grid = new GridArray<16>();
grid[2, 1] = 10;
class GridArray<N: uint32> extends [N].<uint8> {
  get operator[](i: uint32) {
    return ref this[i];
  }
  get operator[](x: uint32, y: uint32) {
    return ref this[y * 4 + x];
  }
}
const grid = new GridArray<16>();
grid[0] = 10;
grid[2, 1] = 10;

For a variable-length array it works as expected:

class GridArray extends [].<uint8> {
  get operator[](x: uint32, y: uint32, z: uint32) {
    return ref this[z * 4**2 + y * 4 + x];
  }
}
const grid = new GridArray();
grid.push(...);
grid[1, 2] = 10;

Views also work as expected allowing one to apply custom indexing to existing arrays:

const grid = new [100].<uint8>();
const gridView = new GridArray(grid);

Implicit Casting

  • In Proposal Specification
  • Proposal Specification Algorithms

The default numeric type Number would convert implicitly with precedence given to decimal128/64/32, float128/80/64/32/16, uint64/32/16/8, int64/32/16/8. (This is up for debate). Examples are shown later with class constructor overloading.

function f(a: float32) {}
function f(a: uint32) {}
f(1); // float32 called
f(1 as uint32); // uint32 called

It's also possible to use operator overloading to define implicit casts. The following casts to a heterogeneous tuple:

class A {
    x: number;
    y: number;
    z: string;
    operator [number, number, string]() {
        return [this.x, this.y, this.z];
    }
}

const a = new A();
const [x, y, z] = A;

Explicit Casting

  • In Proposal Specification
  • Proposal Specification Algorithms
let a := 65535 as uint8; // Cast taking the lowest 8 bits so the value 255, but note that a is still typed as any
let b: uint8 = 65535; // Same as the above

Many truncation rules have intuitive rules going from larger bits to smaller bits or signed types to unsigned types. Type casts like decimal to float or float to decimal would need to be clear.

Function signatures with constraints

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

A typed function defaults to a return type of undefined. In almost every case where undefined might be needed it's implicit and defining it is not allowed.

function f() {} // return type any
// function f(a: int32) { return 10; } // TypeError: Function signature for F, undefined, does not match return type, number.
function g(a: int32) {} // return type undefined
// function g(a: int32):undefined {} // TypeError: Explicitly defining a return type of undefined is not allowed.

The only case where undefined is allowed is for functions that take no parameters where the return type signals it's a typed function.

function f(): undefined {}

An example of applying more parameter constraints:

function f(a: int32, b: string, c: [].<bigint>, callback: (boolean, string) => string = (b, s = 'none') => b ? s : ''): int32 {}

Optional Parameters

While function overloading can be used to handle many cases of optional arguments it's possible to define one function that handles both:

function f(a: uint32, b?: uint32) {}
f(1);
f(1, 2);

Typed Arrow Functions

  • Proposal Specification Grammar
  • Proposal Specification Algorithms
let a: (int32, string) => string; // hold a reference to a signature of this type
let b: (); // undefined is the default return type for a signature without a return type
let c = (s: string, x: int32) => s + x; // implicit return type of string
let d = (x: uint8, y: uint8): uint16 => x + y; // explicit return type
let e = x: uint8 => x + y; // single parameter

Like other types they can be made nullable. An example showing an extreme case where everything is made nullable:

let a: ((number | null) => number | null) | null = null;

This can be written also using the interfaces syntax, which is explained later:

let a: { (uint32 | null): uint32; } | null = null;

Integer Binary Shifts

  • Proposal Specification Algorithms
let a: int8 = -128;
a >> 1; // -64, sign extension
let b: uint8 = 128;
b >> 1; // 64, no sign extension as would be expected with an unsigned type

Integer Division

  • Proposal Specification Algorithms
let a: int32 = 3;
a /= 2; // 1

Type Propagation to Literals

  • In Proposal Specification
  • Proposal Specification Algorithms

In ECMAScript currently the following values are equal:

let a = 2**53;
a == a + 1; // true

The changes below expand the representable numbers by propagating type information when defined.

Types propagate to the right hand side of any expression.

let a: uint64 = 2**53;
a == a + 1; // false
    
let b:uint64 = 9007199254740992 + 9007199254740993; // 18014398509481985

Types propagate to arguments as well.

function f(a: uint64) {}
f(9007199254740992 + 9007199254740993); // 18014398509481985

Consider where the literals are not directly typed. In this case they are typed as Number as expected:

function f(a: uint64) {}
const a = 9007199254740992 + 9007199254740993;
f(a); // 18014398509481984

In typed code this behavior of propagating types to literals means that suffixes aren't required by programmers.

This proposal introduces one breaking change related to the BigInt function. When passing an expression the signature uses bigint(n:bigint).

//BigInt(999999999999999999999999999999999999999999); // Current behavior is 1000000000000000044885712678075916785549312n
BigInt(999999999999999999999999999999999999999999); // New Behavior: 999999999999999999999999999999999999999999n

Alternatively BigInt could remain as it is and bigint would have this behavior. The change is only made to avoid confusion.

This behavior is especially useful when using the float and decimal types.

const a: decimal128 = 9.999999999999999999999999999999999;

Typed Array Propagation to Arrays

Identically to how types propagate to literals they also propagate to arrays. For example, the array type is propagated to the right side:

const a:[].<bigint> = [999999999999999999999999999999999999999999];

This can be used to construct instances using implicit casting:

class MyType {
  constructor(a: uint32) {
  }
  constructor(a: uint32, b: uint32) {
  }
}
let a:[].<MyType> = [1, 2, 3, 4, 5];

Implicit array casting already exists for single variables as defined above. It's possible one might want to compactly create instances. The following new syntax allows this:

let a: [].<MyType> = [(10, 20), (30, 40), 10];

This would be equivalent to:

let a: [].<MyType> = [new MyType(10, 20), new MyType(30, 40), 10];

Due to the very specialized syntax it can't be introduced later. In ECMAScript the parentheses have defined meaning such that [(10, 20), 30] is [20, 30] when evaluated. This special syntax takes into account that an array is being created requiring more grammar rules to specialize this case.

Initializer lists work well with SIMD to create compact arrays of vectors:

let a: [].<float32x4> = [
  (1, 2, 3, 4), (1, 2, 3, 4), (1, 2, 3, 4),
  (1, 2, 3, 4), (1, 2, 3, 4), (1, 2, 3, 4),
  (1, 2, 3, 4), (1, 2, 3, 4), (1, 2, 3, 4)
];

Since this works for any type the following works as well. The typed array is propagated to the argument.

function f(a: [].<float32x4>) {
}
f([(1, 2, 3, 4)]);

Destructuring Assignment Casting

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Array destructuring with default values:

[a: uint32 = 1, b: float32 = 2] = f();

Object destructuring with default values:

{ (a: uint8) = 1, (b: uint8) = 2 } = { a: 2 };

Object destructuring with default value and new name:

let { (a: uint8): b = 1 } = { a: 2 }; // b is 2

Assigning to an already declared variable:

let b:uint8;
({ a: b = 1 } = { a: 2 }); // b is 2

Destructuring with functions:

(({ (a: uint8): b = 0, (b: uint8): a = 0}, [c: uint8]) =>
{
    // a = 2, b = 1, c = 0
})({a: 1, b: 2}, [0]);

Nested/deep object destructuring:

const { a: { (a2: uint32): b, a3: [, c: uint8] } } = { a: { a2: 1, a3: [2, 3] } }; // b is 1, c is 3

Destructuring objects with arrays:

const { (a: [].<uint8>) } = { a: [1, 2, 3] } }; // a is [1, 2, 3] with type [].<uint8>

Array Rest Destructuring

let [a: uint8, ...[b: uint8]] = [1, 2];
b; // 2

A recursive spread version that is identical, but shown for example:

let [a: uint8, ...[...[b: uint8]]] = [1, 2];
b; // 2

Typing arrays:

let [a: uint8, ...b: uint8] = [1, 2];
b; // [2]

Object Rest Destructuring

https://github.com/tc39/proposal-object-rest-spread

let { (x: uint8), ...(y:{ (a: uint8), (b: uint8) }) } = { x: 1, a: 2, b: 3 };
x; // 1
y; // { a: 2, b: 3 }

Renaming:

let { (x: uint8): a, ...(b:{ (a: uint8): x, (b: uint8): y }) } = { x: 1, a: 2, b: 3 };
a; // 1
b; // { x: 2, y: 3 }

Typed return values for destructuring

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Basic array destructuring:

function f(): [uint8, uint32] {
  return [1, 2];
}
const [a, b] = f();

Array defaults

function f(): [uint8, uint32 = 10] {
  return [1];
}
const [a, b] = f(); // a is 1 and b is 10

Basic object destructuring:

function f(): { a: uint8; b: float32; } {
  return { a: 1, b: 2 };
}
const { a, b } = f();

Object defaults:

function f():{ a: uint8; b: float32 = 10; } {
  return { a: 1 };
}
const { a, b } = f(); // { a: 1, b: 10 }

Overloaded example for the return type:

function f(): [int32] {
  return [1];
}
function f(): [int32, int32] {
  return [2, 3];
}
function f(): { a: uint8; b: float32; } {
  return { a: 1, b: 2 };
}
const [a] = f(); // a is 1
const [b, ...c] = f(); // b is 2 and c is [3]
const { a: d, b: e } = f(); // d is 1 and e is 2

See the section on overloading return types for more information: https://github.com/sirisian/ecmascript-types#overloading-on-return-type

Explicitly selecting an overload:

function f(): [int32] {
  return [1];
}
function f(): [float32] {
  return [2.0];
}
const [a: int32] = f();
const [a: float32] = f();

TypeError example:

function f(): [int32, float32] {
  // return [1]; // TypeError, expected [int32, float32]
}

Interfaces

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Interfaces can be used to type objects, arrays, and functions. This allows users to remove redundant type information that is used in multiple places such as in destructuring calls. In addition, interfaces can be used to define contracts for classes and their required properties.

Object Interfaces

  • Proposal Specification Grammar
  • Proposal Specification Algorithms
interface IExample {
  a: string;
  b: (uint32) => uint32;
  ?c: any; // Optional property. A default value can be assigned like:
  // c: any = [];
}

function f(): IExample {
  return { a: 'a', b: x => x };
}

Similar to other types an object interface can be made nullable and also made into an array with [].

function f(a: [].<IExample> | null) {
}

An object that implements an interface cannot be modified in a way that removes that implementation.

interface IExample {
  a: string;
}
function f(a: IExample) {
  // delete a.a; // TypeError: Property 'a' in interface IExample cannot be deleted
}
f({ a: 'a' });

In this example the object argument is cast to an IExample since it matches the shape.

A more complex example:

interface A { a: uint32; }
interface B { a: string; }
function f(a: A) {}
function f(b: B) {}
function g(a: A | B) {
  a.a = 10; // "10" because parameter 'a' implements B
}
g({ a: 'a' });

Array Interfaces

  • Proposal Specification Grammar
  • Proposal Specification Algorithms
interface IExample [
  string,
  uint32,
  ?string // Optional item. A default value can be assigned like:
  // ?string = 10
]
function f(): IExample {
  return ['a', 1];
}

Function Interfaces

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

With function overloading an interface can place multiple function constraints. Unlike parameter lists in function declarations the type precedes the optional name.

interface IExample {
  (string, uint32); // undefined is the default return type
  (uint32);
  ?(string, string): string; // Optional overload. A default value can be assigned like:
  // (string, string): string = (x, y) => x + y;
}
function f(a:IExample) {
  a('a', 1);
  // a('a'); // TypeError: No matching signature for (string).
}

Signature equality checks ignore renaming:

interface IExample {
  ({ (a: uint32) }): uint32
}
function f(a: IExample) {
  a({ a: 1 }); // 1
}
f(({(a:uint32):b}) => b); // This works since the signature check ignores any renaming

An example of taking a typed object:

interface IExample {
  ({ a: uint32; }): uint32;
}
function f(a:IExample) {
  a({ a: 1 }); // 1
}
f(a => a.a);

Argument names in function interfaces are optional. This to support named arguments. Note that if an interface is used then the name can be changed in the passed in function. For example:

interface IExample {
  (string = 5, uint32: named);
}
function f(a: IExample) {
  a(named: 10); // 10
}
f((a, b) => b);

The interface in this example defines the mapping for "named" to the second parameter.

It might not be obvious at first glance, but there are two separate syntaxes for defining function type constraints. One without an interface, for single non-overloaded function signatures, and with interface, for either constraining the parameter names or to define overloaded function type constraints.

function (a: (uint32, uint32)) {} // Using non-overloaded function signature
function (a: { (uint32, uint32); }) {} // Identical to the above using Interface syntax

Most of the time users will use the first syntax, but the latter can be used if a function is overloaded:

function (a: { (uint32); (string); }) {
  a(1);
  a('a');
}

Nested Interfaces

interface IA {
  a: uint32;
}
interface IB {
  (IA);
}
/*
interface IB {
    ({ a: uint32; });
}
*/

Extending Interfaces

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Extending object interfaces:

interface A {
  a: string;
}
interface B extends A {
  b: (uint32) => uint32;
}
function f(c: B) {
  c.a = 'a';
  c.b = b => b;
}

Extending function interfaces:

interface A {
  (string);
}
interface B extends A {
  (string, string);
}
function f(a: B) {
  a('a');
  a('a', 'b');
}

Implementing Interfaces

  • Proposal Specification Grammar
  • Proposal Specification Algorithms
interface A {
  a: uint32;
  b(uint32): uint32;
}
class B {
}
class C extends B implements A {
  b(a) {
    return a;
  }
}
const a = new C();
a.a = a.b(5);

Note that since b isn't overloaded, defining the type of the member function b in the class C isn't necessary.

Once a class implements an interface it cannot remove that contract. Attempting to delete the member a or the method b would throw a TypeError.

Typed Assignment

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

A variable by default is typed any meaning it's dynamic and its type changes depending on the last assigned value. As an example one can write:

let a = new MyType();
a = 5; // a is type any and is 5

If one wants to constrain the variable type they can write:

let a:MyType = new MyType();
// a = 5; // Equivelant to using implicit casting: a = MyType(5);

This redundancy in declaring types for the variable can be removed with a typed assignment:

let a := new MyType(); // a is type MyType
// a = 5; // Equivelant to using implicit casting: a = MyType(5);

This new form of assignment is useful with both var and let declarations. With const it has no uses:

const a = new MyType(); // a is type MyType
const b: MyType = new MyType(); // Redundant, b is type MyType even without explicitly specifying the type
const c := new MyType(); // Redundant, c is type MyType even without explicitly specifying the type
const d: MyType = 1; // Calls a matching constructor
const e: uint8 = 1; // Without the type this would have been typed Number
class A {}
class B extends A {}
const f: A = new B(); // This might not even be useful to allow

This assignment also works with destructuring:

let { a, b } := { (a: uint8): 1, (b: uint32): 2 }; // a is type uint8 and b is type uint32

Function Overloading

  • Proposal Specification Algorithms

All function can be overloaded if the signature is non-ambiguous. A signature is defined by the parameter types and return type. (Return type overloading is covered in a subsection below as this is rare).

function f(x: [].<int32>): string { return 'int32'; }
function f(s: [].<string>): string { return 'string'; }
f(['test']); // "string"

Up for debate is if accessing the separate functions is required. Functions are objects so using a key syntax with a string isn't ideal. Something like F['(int32[])'] wouldn't be viable. It's possible Reflect could have something added to it to allow access.

Signatures must match for a typed function:

function f(a: uint8, b: string) {}
// f(1); // TypeError: Function F has no matching signature

Adding a normal untyped function acts like a catch all for any arguments:

function f() {} // untyped function
function f(a: uint8) {}
f(1, 2); // Calls the untyped function

If the intention is to created a typed function with no arguments then setting the return value is sufficient:

function f(): void {}
// f(1); // TypeError: Function F has no matching signature

Duplicate signatures are not allowed:

function f(a:uint8) {}
// function f(a: uint8, b: string = 'b') {} // TypeError: A function declaration with that signature already exists
f(8);

Be aware that rest parameters can create identical signatures also.

function f(a: float32): void {}
// function f(...a: [].<float32>): void {} // TypeError: A function declaration with that signature already exists

See the Type Records page for more information on signatures.

Overloading on Return Type

function f(): uint32 {
  return 10;
}
function f(): string {
  return "10";
}
// f(); // TypeError: Ambiguous signature for F. Requires explicit left-hand side type or cast.
const a: string = f(); // "10"
const b: uint32 = f(); // 10

function g(a:uint32):uint32 {
  return a;
}
g(f()); // 10

function h(a:uint8) {}
function h(a:string) {}
// h(f()); // TypeError: Ambiguous signature for F. Requires explicit left-hand side type or cast.
h(uint32(f()));

Overloading return types is especially useful on operators. Take SIMD operators, represented here by their intrinsic, that can return both a vector register or mask:

__m128i _mm_cmpeq_epi32 (__m128i a, __m128i b)
__mmask8 _mm_cmpeq_epi32_mask (__m128i a, __m128i b)

Notice Intel differentiates signatures by adding _mask. When translated to real types with operators they are identical however:

//const something = int32x4(0, 1, 2, 3) === int32x4(0, 1, 3, 2); // TypeError: Ambiguous return type. Requires explicit cast to int32x4 or boolean8

With overloaded return types we can support both signatures:

const a: int32x4 = int32x4(0, 1, 2, 3) === int32x4(0, 1, 3, 2);
const b: boolean8 = int32x4(0, 1, 2, 3) === int32x4(0, 1, 3, 2);

For reference, the operators look like:

operator<(v: int32x4): int32x4 {}
operator<(v: int32x4): boolean8 {}

Typed Promises

Typed promises use a generic syntax where the resolve and reject type default to any.

Promise<R extends any, E extends any>
const a = new Promise.<uint8, Error>((resolve, reject) => {
  resolve(0); // or throw new Error();
});

To keep things consistent, the async version has the same return type.

async function f(): Promise.<uint8, Error> {
  return 0;
}

If a Promise never throws anything then the following can be used:

async function f(): Promise.<uint8, undefined> {
  return 0;
}

Right now there's no check except the runtime check when a function actually throws to validate the exception types. It is feasible however that the immediate async function scope could be checked to match the type and generate a TypeError if one is found even for codepaths that can't resolve. This is stuff one's IDE might flag.

Overloading Async Functions and Typed Promises

While async functions and synchronous functions can overload the same name, they must have unique signatures.

async function f(): Promise.<any, Error> {}
/* function f(): Promise.<any, Error> { // TypeError: A function with that signature already exists
    return new Promise.<any, Error>((resolve, reject) => {});
} */
await f();

Refer to the try catch section on how different exception types would be explicitly captured: https://github.com/sirisian/ecmascript-types#try-catch

Generator Overloading

  • Proposal Specification Algorithms

WIP: I don't like this syntax.

var o = {};
o[Symbol.iterator] =
[
  function* (): int32 {
    yield* [1, 2, 3];
  },
  function* (): [int32, int32] {
    yield* [[0, 1], [1, 2], [2, 3]];
  }
];

[...o:int32]; // [1, 2, 3] Explicit selection of the generator return signature
for (const a:int32 of o) {} // Type is optional in this case
[...o:[int32, int32]]; // [[0, 1], [1, 2], [2, 3]]
for (const [a:int32, b:int32] of o) {} // Type is optional in this case

I'd rather do something like:

*operator...(): int32 {
  yield* [1, 2, 3];
}
*operator...(): [int32, int32] {
  yield* [[0, 1], [1, 2], [2, 3]];
}

Object Typing

  • Proposal Specification Grammar
  • Proposal Specification Algorithms

Syntax:

let o = { (a: uint8): 1 };

This syntax is used because like destructuring the grammar cannot differentiate the multiple cases where types are included or excluded resulting in an ambiguous grammar. The parenthesis cleanly solves this.

let a = [];
let o = { a };
o = { a: [] };
o = { (a: [].<uint8>) }; // cast a to [].<uint8>
o = { (a: [].<uint8>):[] }; // new object with property a set to an empty array of type uint8[]

This syntax works with any arrays:

let o = { a: [] }; // Normal array syntax works as expected
let o = { (a: []): [] }; // With typing this is identical to the above

Object.defineProperty and Object.defineProperties have a type key in the descriptor that accepts a type or string representing a type:

Object.defineProperty(o, 'a', { type: uint8 }); // using the type
Object.defineProperty(o, 'b', { type: 'uint8' }); // using a string representing the type
Object.defineProperties(o, {
  'a': {
    type: uint8,
    value: 0,
    writable: true
  },
  'b': {
    type: string,
    value: 'a',
    writable: true
  }
});

The type information is also available in the property descriptor accessed with Object.getOwnPropertyDescriptor or Object.getOwnPropertyDescriptors:

const o = { a: uint8 };
const descriptor = Object.getOwnPropertyDescriptor(o, 'a');
descriptor.type; // uint8

const descriptors = Object.getOwnPropertyDescriptors(o);
descriptors.a.type; // uint8

Note that the type key in the descriptor is the actual type and not a string.

The key value for a property with a numeric type defined in this spec defaults to 0. This modifies the behavior that currently says that value is defaulted to undefined. It will still be undefined if no type is set in the descriptor. The SIMD types also default to 0 and string defaults to an empty string.

Class: Value Type and Reference Type Behavior

Any class where at least one public and private field is typed is automatically sealed. (As if Object.seal was called on it). A frozen Object prototype is used as well preventing any modification except writing to fields.

If every field is typed with a value type then instances can be treated like a value type in arrays. The class also inherits from SharedArrayBuffer allowing instances or arrays to be shared among web workers.

class A { // can be treated like a value type
  a: uint8;
  #b: uint8;
}
class B extends A { // can be treated like a value type
  a: uint16;
}
class C { // cannot be treated like a value type
  a: uint8;
  b;
}

The value type behavior is used when creating sequential data in typed arrays.

const a: [10].<A>; // creates an array of 10 items with sequential data
a[0] = 10;
const b: [10].<A>|null; // reference
b = a;
b[0]; // 10

This is identical to allocating an array of 20 bytes that looks like a, #b, a, #b, ....

An array view can be created over this sequential memory to view as something else. Since this applies to all typed arrays, value type class array views can also be applied over contiguous bytes to create more readable code when parsing binary formats.

class HeaderSection {
  a: uint8;
  b: uint32;
}
class Header {
  a: uint8;
  b: uint16;
  c: HeaderSection;
}
const buffer: [100].<uint8>; // Pretend this has data
const header = ref [].<Header>(buffer)[0]; // Create a view over the bytes using the [].<Header> and get the first element
header.c.a = 10;
buffer[3]; // 10

When using value type classes in typed arrays it's beneficial to be able to reference individual elements. The example above uses this syntax. Refer to the references section on the syntax for this. Attempting to assign a value type to a variable would copy it creating a new instance.

const header = [].<Header>(buffer)[0];
header.c.a = 10;
buffer[3]; // 0

To create arrays of references simply union with null.

const a: [10].<A|null>; // [null, ...]
a[0] = new A();

To change a class to be unsealed when its fields are typed use the dynamic keyword. This stops the class from being used for sequential data as well, so it cannot become a value type in typed arrays.

dynamic class A {
  a: uint8;
  #b: uint8;
}
const a: [10].<A>; // [A, ...]
const b: [10].<A|null>; // [null, ...]

Constructor Overloading

  • Proposal Specification Grammar
  • Proposal Specification Algorithms
class MyType {
  x: float32; // Able to define members outside of the constructor
  constructor(x: float32) {
    this.x = x;
  }
  constructor(y: uint32) {
    this.x = (y as float32) * 2;
  }
}

Implicit casting using the constructors:

let t: MyType = 1; // float32 constructor call
let t: MyType = 1 as uint32; // uint32 constructor called

Constructing arrays all of the same type:

let t = new [5].<MyType>(1);

parseFloat and parseInt For Each New Type

  • In Proposal Specification
  • Proposal Specification Algorithms

For integers (including bigint) the parse function would have the signature parse(string, radix = 10).

let a: uint8 = uint8.parse('1', 10);
let b: uint8 = uint8.parse('1'); // Same as the above with a default 10 for radix
let c: uint8 = '1'; // Calls parse automatically making it identical to the above

For floats, decimals, and rational the signature is just parse(string).

let a: float32 = float32.parse('1.2');

TODO: Define the expected inputs allowed. (See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseFloat). Also should a failure throw or return NaN if the type supports it. I'm leaning toward throwing in all cases where erroneous values are parsed. It's usually not in the program's design that NaN is an expected value and parsing to NaN just created hidden bugs.

Implicit SIMD Constructors

  • In Proposal Specification
  • Proposal Specification Algorithms

Going from a scalar to a vector:

let a: float32x4 = 1; // Equivalent to let a = float32x4(1, 1, 1, 1);

Classes and Operator Overloading

A compact syntax is proposed with signatures. These can be overloaded to work with various types. Note that the unary operators have no parameters which differentiates them from the binary operators.

See this for more examples: tc39/proposal-operator-overloading#29

class A {
  operator+=(rhs) {}
  operator-=(rhs) {}
  operator*=(rhs) {}
  operator/=(rhs) {}
  operator%=(rhs) {}
  operator**=(rhs) {}
  operator<<=(rhs) {}
  operator>>=(rhs) {}
  operator>>>=(rhs) {}
  operator&=(rhs) {}
  operator^=(rhs) {}
  operator|=(rhs) {}
  operator+(rhs) {}
  operator-(rhs) {}
  operator*(rhs) {}
  operator/(rhs) {}
  operator%(rhs) {}
  operator**(rhs) {}
  operator<<(rhs) {}
  operator>>(rhs) {}
  operator>>>(rhs) {}
  operator&(rhs) {}
  operator|(rhs) {}
  operator^(rhs) {}
  operator~() {}
  operator==(rhs) {}
  operator!=(rhs) {}
  operator<(rhs) {}
  operator<=(rhs) {}
  operator>(rhs) {}
  operator>=(rhs) {}
  operator&&(rhs) {}
  operator||(rhs) {}
  operator!() {}
  operator++() {} // prefix (++a)
  operator++(nothing) {} // postfix (a++)
  operator--() {} // prefix (--a)
  operator--(nothing) {} // postfix (a--)
  operator-() {}
  operator+() {}
  get operator[]() {}
  set operator[](...args, value) {}
  operator T() {} // Implicit cast operator
}

Examples:

class Vector2 {
  x: float32;
  y: float32;
  constructor(x: float32 = 0, y: float32 = 0) {
    this.x = x;
    this.y = y;
  }
  length(): float32 {
    return Math.hypot(this.x, this.y); // uses Math.hypot(...:float32):float32 due to input and return type
  }
  operator+(v: Vector2): Vector2 { // Same as [Symbol.addition](v:Vector2)
    return new vector2(this.x + v.x, this.y + v.y);
  }
  operator==(v: Vector2): boolean {
    const epsilon = 0.0001;
    return Math.abs(this.x - v.x) < epsilon && Math.abs(this.y - v.y) < epsilon;
  }
}

const a = new Vector2(1, 0);
const b = new Vector2(2, 0);
const c = a + b;
c.x; // 3

Again this might not be viable syntax as it dynamically adds an operator and would incur performance issues:

var a = { b: 0 };
a[Symbol.additionAssignment] = function(value) {
  this.b += value;
};
a += 5;
a.b; // 5

Static Operator Overloading