This javascript library allows us to define types, abstract types, interfaces and traits. Types can be extends by other types and can use multiple traits and interfaces. Also, interfaces and traits can extends their kinds.
Type.js uses chained [[Prototype]]
mechanism. So this means that all the inherited type and trait methods will collected according proto area and those proto objects will be chained. Type.js bakes almost the same object as you would get when you instantiate the class X extends Y
structure, which is a syntactic sugar in Modern JavaScript.
Traits can be considered as reusable, small ability pieces that can be shared across types.
They can define methods and props and extend other traits. With thus, extender trait becomes an instanceof
extended trait and all the properties that came from extended trait will be exist in the extender trait. When a Type used that final trait, it will have all the properties that came from extender and extendeds traits. After that, the type and instances created from it, also becomes an instanceof
all of those traits.
Interfaces can be considered as blueprints of types. Arguments of methods, their types, whether they are required or not, and the type of return value can be declared with interfaces. The only thing you can't do is define the body of a method. Interfaces can draw outlines of methods and properties.
So when a type implements one (or more) interfaces, that type have to follow the rules defined in those interfaces. If incompatibility detected an error is thrown and execution stops.
It is immediately checked whether required arguments on methods are defined and if not, error will appear before the relevant method has ever run.
The same thing is done for properties. If required properties are not defined or if the interface declare a type and the property currently does not hold that type of data, errors are thrown.
However, the methods and properties is constantly monitored during runtime to see whether they're called/writed legally. It does this by placing a proxy method instead of the main method you wrote and getter/setter for properties. This may affect performance, but since Type.js is completely native JavaScript, you can enclose the entire interface architecture in if-else blocks. If there is an ENV variable in your work environment that holds values such as development
and production
, types can make their decision to implements interfaces or not, depending on that env value. Thus, while you use the interface in the development environment, you can ensure that it is not used in the production environment. You can even ensure that the interface codes do not contamine the compiled codes if your bundler shake trees.
Interfaces can extend as many interfaces as needed. With thus, extender interfaces becomes an instanceof
extended interfaces. The type that implements extender interface should also have to implements all the extended interface rules. After that, the type and instances baked from it, also becomes an instanceof
all of those interfaces.
Yes, methods are required already and we can define required props if we wish in the interfaces. Types that implemented it should define them, this is a debt for types and there is no escaping from it. But there is one tricky part about it which is we can leave this debt to a child type. If a type declares itself as a abstract type then that type doesn't have to define the rules that coming from implemented or inherited interfaces. But a child type at the any level of the inheritance that extends this abstract type directly or indirectly should define the required things. If not, errors will be thrown. Debts should be get paid eventually.
npm install @iceylan/type.js
After installed the Type.js in your project, you can import the modules that you needed as ES modules. Currently require
ing with commonjs doesn't supported.
import { Type, Trait, Interface } from "@iceylan/type.js";
const Foo = Type( "Foo" );
const CanBreath = Trait( "CanBreath" ).body(
{
breath( perMinute )
{
console.log( "Yay! I can breath " + perMinute + " times per minute." );
}
});
Trait methods are added to the prototype bags of the types that use it. Therefore, the instance's context
(this word) refers to the type to which they belong, not trait object. However, all properties defined on types and traits are added to the instance that produced from the final type after passing through a property inheritance algorithm. This algorithm produces same result with the class mechanism that comes with EcmaScript 6. That means you won't see any property in any [[Prototype]]
sections.
const CanBreathUnderwater = Trait( "CanBreathUnderwater" );
CanBreathUnderwater.uses( CanBreath,
{
breath: "baseBreath"
});
CanBreathUnderwater.body(
{
breathUnderwater()
{
this.baseBreath( 10 );
console.log( "Whoa! I'm breathing under water. Did you see how coool I am!!" );
}
});
Traits can extend another trait with uses
method. If we want to inherit another one, we can put another uses method at the end of the chain. We can also rename the inherited trait methods as we wish. In the future, when a type uses the final trait, the functions will be included in the type with their changed names.
const Creature = Type( "Creature" )
.uses( CanBreathUnderwater, { breathUnderwater: "breath" })
.body(
{
construct()
{
console.log( "Yay! I'm alive." );
}
live()
{
console.log( "Since I'm alive, why can't I live?" );
}
});
The construct
method performs the constructive operations of the type. Each time
created an instance from a type, the method runs once, taking the given parameters. In this
method, we can perform the initializations works, create initial values for properties of the type.
The uses
method on the type objects allow us to use traits. If we want to use another trait we have to prepend another use method to the chain like Type( ...something ).uses( ...trait1, ...rename map).uses( ...trait2, ...rename map)
.
const Creature = Type( "Creature" ).uses( CanBreathUnderwater,
{
breath: "exhale"
});
Now, the Creature type has a exhale method instead of breath.
const AnimalContract = Interface( "AnimalContract", animals =>
{
animals.property( "abilities", Array ).required();
animals.method( "move", moving =>
{
moving.argument( "speed", Number ).required();
moving.argument( "x", Number ).default( 0 );
moving.argument( "y", Number ).default( 0 );
moving.argument( "z", Number ).default( 0 );
moving.returns( Object );
});
});
This interface let us declare strictly defined properties, methods, arguments and return types and keep us on track while for example we code animals.
const WarmBloodedCreatureContract = Interface( "WarmBloodedCreatureContract" );
WarmBloodedCreatureContract
.extends( AnimalContract )
.body( warmBloodeds =>
{
warmBloodeds.property( "heartBeatSpeed", Number ).required();
});
We can declare rules as second argument of the Interface
or use body
method for it. Now the WarmBloodedCreatureContract declares two properties and one method.
const Animal = Type( "Animal" )
.extends( Creature )
.implements( WarmBloodedCreatureContract )
.body(
{
abilities: [],
heartBeatSpeed: 10,
construct()
{
// first, let the creature's constructor work
parent();
// now, the actions concerning the Animal type can work
console.log( "I'm not a veggy, there is an animal inside of me" );
},
move( speed, x, y, z )
{
console.log( "Get out of my way! I'm moving!" );
return {}
}
});
We can use implements
method to declare that we are going to follow rules of an interface. Method accepts multiple interfaces like implements( iface1, iface2, ...)
.
Also, type.js injects a magic parent
word in every method we defined. This works same as the super
that comes with ES6. You can use the parent in all methods of your types, traits and access every parent method with it.
// ... type or trait definitions going here
foo()
{
// accessing parent's foo method
parent();
// same as above
parent( "foo" );
// we can access another parent method
parent( "bar" );
// passes arguments parent.foo( "a", "b" )
parent([ "a", "b" ]);
// same as above
parent( "foo", [ "a", "b" ]);
},
// ... type or trait definitions continues here
That will help us to easily access overloaded or any parent method and reuse their abilities.
const CanSpeak = Trait( "CanSpeak" ).body(
{
speak( words )
{
console.log( "I'm talking!", words );
}
});
Nowadays, the only species that can speak is humans, but hey, who knows maybe in the future another species can learn to speak. So, defining how to speak in a trait is a clever way to make the ability reusable between species.
const Human = Type( "Human" ).extends( Animal ).uses( CanSpeak, { speak: "talk" }).body(
{
construct( name )
{
// first, let's run animal's construct
parent();
// now, we can initialize the Human
console.log( "And my name is " + name );
// it should breath immediately otherwise it may die just now
this.breath();
// it should speak just now or it may still die
this.talk( "Those are my first words." );
}
live()
{
// this method has been inherited from the "creature" type up to this point, but we
// had to defined the "live" method here again, so we rejected the inheritance, but
// we want to use benefits of the parent's live method and add something extra after that
parent();
// now we can improve our heritage
console.log( "I live like a human!" );
}
});
parent mechanism can bubble. That means if you call parent in a type method, it'll let you to access parent type, obviously. If we call parent in the method that we accessed from child then that make us dive one level deeper again in the inheritance. You can imagine that like parent().parent()
and so on.
But that doesn't mean we can chain the parent calls. The parent magical method returns the value that returned by the accessed method of the parent type. This means that you won't have a direct connection with the parent of the parent type.
const ismail = Human.create( "İsmail" );
ismail.live();
ismail.talk( "Hello world!" );
All parameters given to the create
method are passed to the construct
method of the type.
Type.js provide abilities to test "is a" and "can be" relations. instanceof
expression also supported.
CanBreathUnderwater.behave( CanBreath ); // true
CanBreathUnderwater instanceof CanBreath; // true
CanBreath instanceof CanBreathUnderwater; // false
Since traits can use each other we can test it with behave
method or put them into instanceof
expression.
WarmBloodedCreatureContract.is( CreatureContract ); // true
WarmBloodedCreatureContract instanceof CreatureContract; // true
Interfaces can extends each other, too. We can test it with is
method or put them into instanceof
expression.
Human.is( Creature ); // true
Human instanceof Creature; // true
Types also extends each other. So we can test it with is
method. instanceof
expression works same as well.
Human.behave( CanBreath ); // true
Human instanceof CanBreath; // true
We know that types can use traits and we can test it with behave
method or we can just use instanceof
expression. Please pay attention that there are no type used CanBreath
trait directly in the inheritance chain. Instead the Creature
type used the CanBreathUnderwater
trait which it uses the CanBreath
trait. All of these are means that the Human
type uses CanBreath
trait indirectly but testing the relation will give us a true, as it's supposed to be.
Human.is( AnimalContract ); // true
Human instanceof AnimalContract; // true
Types can implements interfaces and we can test it with is
method or instanceof
expression. Testing behaviours between inherited interface are same as mentioned above for traits. AnimalContract
is an indirectly inherited interface for Human
type but testing it will give us true.
ismail.behave( CanBreath ); // true
ismail instanceof CanBreath // true
ismail.is( AnimalContract ); // true
ismail instanceof AnimalContract; // true
ismail.is( Human ); // true
ismail instanceof Human; // true
Human.is( ismail ); // false
Human instanceof ismail;
// TypeError: Right-hand side of 'instanceof' is not callable
Tests on instances results exactly same as mentioned above. Types, traits or interfaces can't test instances, but instances can.