Draft for Hybrid Typing synthax, fixing Type Annotations proposal

Hybrid typing synthax

Brings static typing alongside of dynamic typing.

Types as comments will break forward compatibility and close forever future type language feature.

Extending aspects of Type annotations proposal.

Best chance to improved dev experience and last chance to add types to ecmascript.

Key points

Effortless

  • You don't have to type if you don't want.
  • Imported code will (if typed) cast value for you and only bring you transparent safety and performance.

Costless

  • Declarations and typed code reduce memory footprint and engine abstractions/instructions during runtime.
  • Parse time is reduced as type error will fail fast (before runtime for explicit mismatch).
  • Bundler could remove types if you focused on size and keep some file typed for performance and runtime safety.

Progressive

  • You can type some parts without affecting the rest of your codebase.
  • TC39 type system functionnalities can be extanded progressively by proposals, only some core functionnality is needed to start.

Granular

  • Only some specific performance sensite parts (eg: complexe computation) or type safety (eg: assert fetching json) can be type.

Wasm

If type annotations evolved to hybrid typing runtime

  • Don't break backward compatibility.
  • Zero cost and dev friendely.
  • The developper choose, use type system or not (interroperability).
  • Bring security and performances.
  • Can be directly optimized/compiled by engine, no need for JSObject or JSValue (V8) with type testing and deoptimizations.
  • One of the most requested features with no effect for js developper who don't wan't typing.
  • Embeded architectures (arduino, esp, ...).
  • High performance code.
  • Reduce memory footprint (less typechecking in internal compiled code).

All legacy typed JS work and is default behaviour (no fear for vanilla js lovers)

New possibilities for type lovers

Supersets like typescript and flow can transpile to "static typed" js

Rules

  1. All legacy and no-typed variables are considered as any (actual behaviour) any type is dynamic.
  2. Explicit typed code is statically typed and can perform checks before runtime.
  3. Explicit typed variables are castable to any without any restriction.
  4. Cast (not coercion) from any to typed is runtime asserted for objects and before for primitives.
  5. Types assertion (eg: as keyword in typescript):
    • Perform runtime type cast until mismatch and then raise a TypeError for any.
    • Typed code is allowed to perform check berfore runtime.
  6. Explicit cast needed for no statically analysable code
    const any = 5
    const count: number = any //Ok, implicit cast because statically analysable 
    const users: User[] = []
    const apiResponse = await fetch('https://api.example.com/users')
    users.push(await apiResponse.json()) //Error, can't analyse apiResponse type
    users.push(await apiResponse.json() as Users[]) //Ok, cast is explicitely write
    

Futher suggestions

  • Types as firstclass primitives eg: typedef
  • New Global object Type to add new specific type operations (because introducing new operators can be tricky)
    interface Type {
        is(value: any, type: typedef) => boolean
        of(value: any) => typedef //structural typedef
        compare(type1: typedef, type2: typedef) => 'equal' | 'subset' | 'superset'
    }
    
  • Narrowed primitives
    i32, u32, ...
    
    Some engines can implementes their own subset based on existing types (type aliased in other environnement), for instance for IOT with types like i8, u8, ...

Examples

(see Typescript use cases and Rust to imagine the revolution)

Declaration

let text = 'some text'
let count: number = 5

text = 5 //Ok
count = 'some text' //Runtime (or before if aviable) error

Equivalent to strongly typed code

let text: any = 'some text'
let count: number = 5

text = 5 //Ok
count = 'some text' //Runtime error

Implicit cast

const users: User[] = []
const response = await fetch('https://api.example.com/users')
users.push(await response.json()) //Runtime throw if cast error

Explicit cast

const response = await fetch('https://api.example.com/users')
const users = await response.json() as User[] //Runtime throw if cast error
//Or
const users: User[] = await response.json()
//Or
const users = await response.json::<User[]>() //synthax from type annotations proposal examples
//Runtime throw if cast error

const anyTyped = '5'
foo(anyTyped as number) // Error, cast is not coercion

Generics

function copyEntry<T extends Entry>(entry: T): T {
    return new T({... entry})
}

const user1 = new UserEntry(/*...*/)
const user2 = copyEntry(user1)

//functionnal programming
class None {
    unwrap() {
        throw new Error('none value')
    }
}

class Some<T> {
    #value: T
    constructor(value: T) {
        this.#value = value
    }
    unwrap(): T {
        return this.#value
    }
}

type Option<T> = Some<T> | None

const value: Option<number> = None
const value2: Option<number> = Some(5)
const value3: Option<number> = Some('') //TypeError (before runtime is aviable)

const neverTypeError: number = value2.unwrap()

New use cases

type Api = Type.of(await (await fetch('https://api.example.com/type_format')).json())

function reshape<T>(payload/* no type = any */): T {
    /* ... */
}

const payload = /* ... */

await fetch('https://api.example.com/', {
    method: 'POST',
    body: JSON.stringify(reshape<Api>(payload))
})

Optimisation issues

  • Declaration

    let count: number
    

    V8 inspired pseudo code

    //actual
    JSValue count;
    
    //new
    f64 count;
    
  • Functions

    function mean(a: number, b: number): number {
        return (a + b) / 2
    }
    
    const result: number = mean(2, 4)
    

    V8 inspired pseudo code

    //actual no-opt
    JSFunction mean(JSValue a, JSValue b) {
        return JSAdd(a, b) / 2;
    }
    
    JSValue result = mean(2, 4);
    
    //actual opt
    JSFunction mean(JSValue a, JSValue b) {
        if (JSValueIsNumber(a) && JSValueIsNumber(b)) return (a + b) / 2;
        deoptimize()
    }
    
    f64 result = mean(2, 4);
    
    //new directely opt
    f64 mean(f64 a, f64 b) {
        return (a + b) / 2;
    }
    
    f64 result = mean(2, 4);
    

Some questions.

  1. Is the following an example of a runtime error? Or is this an error that's given by some build-time tooling (would there be some official TC39 JavaScript static-analyzer tool?). Or both?
  1. The above example makes it feel like updating a library from no types to having types could force end-users to also use types. For example, say we start with this scenario:
// myAwesomeLib v1
export function squareIt(n) {
  if (typeof n !== 'number') throw ...;
  return n**2;
}

// User of my library
import { squareIt } from 'myAwesomeLib';
const n = globaThis.mySpecialNumb;
console.log(squareIt(n));

Then myAwesomib updates to have type information

// myAwesomeLib v1
export function squareIt(n: number) {
  return n**2;
}

Is it true that the end-user's code would stop working, because they're not casting their n variable (which could be any type) to a number, via the "as" operator?

It's an early draft so it's more a suggestion but the best is to specify that implementers throw an error before runtime as it is not possible to predict the type in any way. It will be better for code comprehension to explicitely says (for non literal or class instance object) to say "I expect this type". Methods like fetch.json or JSON.parse product data from string and can often be a source of unknown. Writing explicit cast close to declaration seems to be a good idea for those who implement types in their codebase. Some example inspired by type annotation proposal:

const apiResponse = await (await fetch('https://api.example.com/users')).json() as User[]
//or
const apiResponse: User[] = await (await fetch('https://api.example.com/users')).json()
//or
const apiResponse = await (await fetch('https://api.example.com/users')).json::<User[]>()

It could enforce end user to use correct runtime types, not statically ones. any is runtime casted (not coerced) to required type, only static type mismatch is catch before.

// myAwesomeLib v2
export function squareIt(n: number) {
  return n**2;
}

//User code
squareIt(5 /*literals*/) //Ok, static analyze
const two = 2
squareIt(two /*any type (legacy)*/) //Ok + const primitive can be statically analyzed = no runtime check and possible direct opt
const three: string = '3'
squareIt(three) //Error before runtime, obviously
1 Like