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
- Meta types are the abstract types you get when you use typeOf() on a type object
- Data types represent a set of concrete values and can only be leaf nodes in the type graph
- 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:
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 noTStruct
type that's a parent type of all structs. Unless a struct type is explicitly created with abstract types as parents, its a child ofTAny
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
.