The JSOS Type System The Type Graph Abstract Types Declaring Abstract Types Using Abstract Types Composite Types Constructors Traits Dependent Types

The JSOS Type System

JSOS's type system can be described as dynamic, reified, nominal, and parametric.

Dynamic because the types are checked at runtime. No compiler or transpiler is needed.

Reified because the type system is comprised of actual things you can manipulate at runtime. Reified basically means "made tangible".

Nominal because types are not compared by their structure but rather by their names and place in the type graph. For example, two objects instantiated from two different structs are not considered equal even if they have all the same keys and values.

Parametric because types can be subtyped using parameters. For example, an aggregate type can be parameterized by what kinds of types are allowed for its values.

The Type Graph

In JSOS, we differentiate the following kinds of types:

  • Abstract types cannot be instantiated and determine the type graph's structure
  • Data types represent a set of concrete values and can only be leaf nodes in the type graph
    • JS types are type objects representing the basic JavaScript types
    • Traits are types that bundle zero or more methods and act as an interface
  • Dependent types are subtypes of non-abstract types whose definition depends on a value

Types can have parents and abstract types can also have children. These relationships are what forms the type graph:

JSOS Type Graph
Overview of the JSOS type graph

Why is it called a type graph and not a type hierarchy? Because types can have more than one parent, meaning JSOS supports multiple inheritance.

Abstract Types

Abstract types are types that cannot be instantiated. Their usefulness comes from the fact that they can have parents and children. These relationships form JSOS's type graph.

Declaring Abstract Types

An abstract type can be created with the Type proxy:

import { Type } from "@fed1/jsos";

const { TMyType } = Type;

This creates a variable TMyType containing an abstract type object.

New abstract types created as above are automatically derived from TAny, which is the root type of the type graph.

To declare a type with parents, you can call Type as a function and supply an array of abtract types as the first argument:

const { THovercraft } = Type(TSeaVehicle, TLandVehicle);

All abtract types must start with a T followed by an uppercase letter. An error is thrown if you accidentally create a type with a name that doesn't conform to this schema.

This is enforced to clearly distinguish abstract types from types that can be instantiated.

Using Abstract Types

Now that we have a type, how can you use it? The most basic usecase is checking whether or not a value is a valid type using the isA function. Since MyType is abstract and there are concrete types derived from it, we first need to create a concrete type that can instantiate values to match the abstract type. We can use a struct for that like so:

import { Type, Struct, isA, JSNumber, JSString } from "@fed1/jsos";

const { TMyType } = Type;

const { MyConcreteType } = Struct({
    name: JSString,
    id: JSNumber
})(TMyType);

const mct = MyConcreteType.new({
    name: "Foo",
    id: 1
});

isA(mct, MyConcreteType); // true
isA(mct, TMyType); // true

Composite Types

Composites (objects) in JSOS are called structs. Struct types are blueprints for creating objects, similar to classes in regular JavaScript. However, structs are immutable and their properties are typed. They only contain data, not behavior.

The Struct proxy can be used to create new struct types:

import { Struct, JSNumber, JSString } from "@fed1/jsos";

const { User } = Struct({
    id: JSNumber,
    name: JSString
});

This creates a new composite type User. Struct.<name>() is a "magic method" which creates a struct type with an internal name that corresponds to the property name. Internal names are important for debugging purposes, that's why all struct types must be created this way.

Struct creates completely new composite types. There is no TStruct type that's a parent type of all structs. Unless a struct type is explicitly created with abstract types as parents, its a child of TAny in the type graph.

The object given to this magic method is what's called a schema. A schema describes the shape of objects created from the struct type. The property names of the schema directly correspond to the to-be-created object's properties. The values of those properties in the schema are the types that a value of this property must have.

Although it is possible to add JSFunction properties to a struct type, the preferred way in JSOS is to write multimethods instead.

This is how we can create a User object:

const hanna = User.new({
    id: 1,
    name: "Hanna"
});

The resulting object can be used like any other JavaScript object:

> hanna.id
1
> hanna.name
'Hanna'
> hanna.foo
undefined

Struct types create frozen (immutable) objects, so assigning new properties doesn't work. Assigning new values to existing properties results in an error:

> hanna.id = 5
TypeError: Cannot assign to read only property 'id' of object '[object User]'

Constructors

Struct types have a constructor property .new(). This is actually not just a regular function, it's a multimethod! This means that, just like with all other multimethods, you can add new methods to it:

import { Type, Struct, isA, def, JSNumber, JSString } from "@fed1/jsos";

const { MyType } = Struct({
    name: JSString,
    id: JSNumber
});

def(MyType.new, [JSString, JSNumber], (name, id) => MyType.new({name, id}));

const mct = MyType.new("Foo", 1);

Each custom constructor method should return with a call to the default constructor, which expects a regular JSObject containing all the properties required by the struct.

Traits

Dependent Types

Dependent types are types that depend on a value. They are created by calling the .where() method on a type object with a predicate function:

const TIdentifier = JSString.where(s => s.length > 3);

The predicate function is given any value matched against the type. It is used to ascertain that certain criteria are met for a value of that type. If the criteria are met, the predicate function should return true, and if it does, the value is considered to be of that dependent type in addition to being of the original type.

Dependent types have a higher specificity than the type they are derived from. This means that if a multimethod is specialized for both the original type and the dependent type, the specialization for the dependent type will be preferred.

Dependent types are not, however, subtypes or children of the type whose .where() method was used to create them:

> isA(TIdentifier, JSString)
false

The value described by a dependent type must first also be of the type whose .where() method was used to create the dependent type:

> const { Container } = Struct({value: JSNumber});
undefined
> const TAboveZeroContainer = Container.where(c => c.value > 0);
undefined
> isA(Container.new({value: 3}), TAboveZeroContainer)
true
> isA({value: 3}, TAboveZeroContainer)
false

Even though both the container and the object with the same value property in this example are JavaScript objects, only the container is recognized as being of type Container, and so only it can be a TAboveZeroContainer.