Skip to content

Allows to define types, abstract types, interfaces and traits. Types can be extends by other types, uses traits and implements interfaces in multiple manner. Also, interfaces and traits can extends their kinds.

License

Notifications You must be signed in to change notification settings

ismailceylan/type.js

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

npm bundle size (scoped) npm bundle size (scoped) npm nycrc config on GitHub npm NPM

Type.js

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.

Mechanism

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

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

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.

Installation

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 requireing with commonjs doesn't supported.

import { Type, Trait, Interface } from "@iceylan/type.js";

const Foo = Type( "Foo" );

Usage

Creating Traits

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.

Extending Traits

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.

Creating Types

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).

Renaming Trait Methods

const Creature = Type( "Creature" ).uses( CanBreathUnderwater,
{
    breath: "exhale"
});

Now, the Creature type has a exhale method instead of breath.

Creating Interfaces

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.

Extending Interfaces

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.

Extending Types

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.

Creating Abilities As Traits

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.

Creating Final Types

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.

Creating Instances From Types

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.

Testing "Is A" Relations

Type.js provide abilities to test "is a" and "can be" relations. instanceof expression also supported.

Same Kind Relations

1. relations between traits
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.

2. relations between interfaces
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.

3. relations between types
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.

Cross Kind Relations

1. relations between types and traits
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.

2. relations between types and interfaces
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.

Tests On Instances

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.

About

Allows to define types, abstract types, interfaces and traits. Types can be extends by other types, uses traits and implements interfaces in multiple manner. Also, interfaces and traits can extends their kinds.

Topics

Resources

License

Stars

Watchers

Forks