Neophobe Plebeian Mumpsimus

    signet
    TypeScript icon, indicating that this package has built-in type declarations

    7.1.2 • Public • Published

    Signet

    The fast, rich runtime documentation-through-type system for Javascript

    At its core, Signet aims to be a first-line-of-defense documentation library for your code. By attaching and enforcing rich type information to your functions, you communicate with other developers what your intent is and how they can use your code. Sometimes that other developer is future you!

    Although Signet is a deep, rich, extensible type system, the most important first takeaway is Signet is easy to use. Unlike other documentation libraries which require a lot of time and effort to get familiar with, Signet provides a familiar, simple means to fully document your behavior up front, like this:

        const add = signet.enforce(
            'a:number, b:number => sum:number`,
            (a, b) => a + b
        );
    

    Obviously, this is a trivial example, but it is easy to immediately understand what our add function requires and what it will do. More importantly, if someone were to try to use our function incorrectly, they would get a clear message:

        add('foo', 23); // TypeError: Expected value of type a:number, but got foo of type string
    

    Moreover, if this developer wanted to understand what the add function expected, they could simply request the signature:

        console.log(add.signature); // a:number, b:number => sum:number
    

    All of a sudden, those API endpoints which were left undocumented can be easily updated to provide parameter and result information without a lot of extra developer time. This kind of in-code documentation and type checking facilitates tribal knowledge even if a member of the tribe has long left.

    Finally, Signet won't let your documentation get out of date. Since Signet does real type checking and a review of your function properties against your signature, if you add parameters or change your function, Signet will let you know your documentation is out of date.

    All of this only scratches the surface of what you can do with Signet. You can define your own types, use constructed and algebraic types and even define macros to alter type strings just in time. Beyond that, Signet is 100% ECMAScript 5.1 (Harmony) compliant, so there is no need to transpile anything. As long as your code works, Signet works.

    Remember, code is not just a program to be run, it is a document programmers read. Wouldn't you like your document to tell you more?

    Install Signet

    Signet is available through NPM:

    npm i signet --save

    You can also find it on the NPM site for more information:

    https://www.npmjs.com/package/signet

    Library Usage

    First it is recommended that you create a types file so the local signet object can be cached for your module:

        const signet = require('signet')();
        
        //my aliased type
        signet.alias('foo', 'string');
    
        //If you're in node, be sure to export your signet instance!
        module.exports = signet;
    

    Now, include your types file into your other files and the signet types object will be properly enclosed in your module. Now you're ready to get some type and document work done:

    const signet = require('./mySignetTypesFile');
    
    const range = signet.enforce(
        'start < end :: start:int, end:int, increment:leftBoundedInt<1> => array<int>',
        (start, end, increment) => {
            let result = [];
    
            for(let i = start; i <= end; i += increment) {
                result.push(i);
            }
    
            return result;
        }
    );
    

    Namespacing Types

    It's a common need to namespace types in order to declare types for different contexts as you develop your application. In statically typed languages like C# and Java, this concept is built into the language.

    Javascript doesn't have this concept, but Signet provides a means to work within a namespace in order to segregate type concepts without creating painful names.

    In node, Signet namespaces are simply separate instances of Signet. This means you can do the following:

    const signetFactory = require('signet');
    
    const Permissions = signetFactory();
    const Signup = signetFactory();
    
    Permissions.defineDuckType('claim', {
        id: 'int',
        name: 'string'
    });
    
    Permissions.defineDuckType('user', {
        userId: 'int',
        claims: 'array<claims>'
    });
    
    // Don't use this crummy email validation,
    // it's for demonstration purposes only.
    Signup.alias('email', 'formattedString<[^@]+@.*\..*>');
    
    Signup.defineDuckType('user', {
        name: 'string',
        email: 'email',
        username: 'string',
        password: 'string'
    });

    In the client, namspacing works a little differently:

    const Permissions = signet.new();
    const Signup = signet.new();
    
    Permissions.defineDuckType('claim', {
        id: 'int',
        name: 'string'
    });
    
    Permissions.defineDuckType('user', {
        userId: 'int',
        claims: 'array<claims>'
    });
    
    // Don't use this crummy email validation,
    // it's for demonstration purposes only.
    Signup.defineDuckType('email', 'formattedString<[^@]+@.*\..*>');
    
    Signup.defineDuckType('user', {
        name: 'string',
        email: 'email',
        username: 'string',
        password: 'string'
    });

    This means you can work with a variety of types without type name collisions, keeping your contexts well bounded and easier to manage!

    Basic Operators and Syntactic Characters

    • Type names -- All primary type names should adhere to the list of supported types below
    • Subtype names -- Subtype names must not contain any reserved characters as listed next
    • <> -- Angle brackets are for handling type constructors and verify value only when type logic supports it
    • [] -- Brackets are meant to enclose optional values and should always come in a matched pair
    • => -- Function output "fat-arrow" notation used for expressing output from input
    • , -- Commas are required for separating types on functions
    • : -- Colons allow for object:instanceof annotation - This is not required or checked
    • ; -- Semicolons allow for multiple values within the angle bracket notation
    • () -- Optional parentheses to group types, which will be treated as spaces by interpreter

    Example function signatures:

    • Empty argument list: "() => function"
    • Simple argument list: "number, string => boolean"
    • Subtyped object: "object:InstantiableName => string"
    • Typed array: "array<number> => string"
    • Optional argument: "array, [number] => number"
    • Curried function: "number => number => number"

    Primary Types

    Signet supports all of the core Javascript types as well as a few others, which allow the core typesystem to be approachable, clear and easy to relate to for anyone familiar with Javascript and its built-in dynamic types.

    List of primary types:

    • *
    • array
    • bigint - * -> nativeNumber -> bigint
    • boolean
    • function
    • null
    • number - * -> nativeNumber -> bigint
    • object
    • string
    • symbol
    • undefined

    Extended types

    Signet has extended types provided as a separate module. In the node environment, the extended types are included in the required module, but can be removed by pointing to the signet.js module directly. In the browser environment, signet.min.js and signet.types.min.js in that order to include the extended types.

    Extended types, and their inheritance chain, are as follows:

    • arguments - * -> variant<array; object>
    • bounded<typeString:type, min:number, max:number> - * -> bounded
    • boundedFiniteInt<min:number;max:number> - * -> boundedFiniteInt
    • boundedFiniteNumber<min:number;max:number> - * -> boundedFiniteNumber
    • boundedInt<min:number;max:number> - * -> boundedInt
    • boundedNumber<min:number;max:number> - * -> boundedNumber
    • boundedString<minLength:int;maxLength:int> - * -> boundedString
    • composite - * -> composite (Type constructor only, evaluates left to right)
    • decimalPrecision<precision:leftBoundedInt<0>> - number -> decimalPrecision
    • decreasing<typeString:type>- * -> array -> (sequence ->) decreasing
    • formattedString<regex> - * -> string -> formattedString
    • int - * -> nativeNumber -> int
    • increasing<typeString:type>- * -> array -> (sequence ->) increasing
    • leftBounded<typeString:type, min:number> - * -> leftBounded
    • leftBoundedFiniteInt<min:number> - * -> leftBoundedFiniteInt
    • leftBoundedFiniteNumber<min:number> - * -> leftBoundedFiniteNumber
    • leftBoundedInt<min:number> - * -> leftBoundedInt
    • leftBoundedNumber<min:number> - * -> leftBoundedNumber
    • leftBoundedString<min:number> - * -> leftBoundedString
    • monotone<typeString:type>- * -> array -> (sequence ->) monotone
    • not - * -> not (Type constructor only)
    • regexp - * -> object -> regexp
    • rightBounded<typeString:type, max:number> - * -> number -> rightBounded
    • rightBoundedFiniteInt<max:int> - * -> rightBoundedFiniteInt
    • rightBoundedFiniteNumber<max:int> - * -> rightBoundedFiniteNumber
    • rightBoundedInt<max:int> - * -> rightBoundedInt
    • rightBoundedNumber<max:int> - * -> rightBoundedInt
    • rightBoundedString<max:int> - * -> rightBoundedString
    • sequence<typeString:type> - * -> array -> sequence
    • tuple<type;type;type...> - * -> object -> array -> tuple
    • unorderedProduct<type;type;type...> - * -> object -> array -> unorderedProduct
    • variant<type;type;type...> - * -> variant

    Macro Types

    Signet supports type-level and signature-level macros. There are a small set of built-in macros which are as follows:

    • () - type-level macro for *
      • Example: () becomes *
    • !* - type-level macro for not<variant<undefined, null>>
      • Example: definedType:!* becomes definedType:not<undefined, null>
    • ^typeName - type-level macro for not<typeName>
      • Example: notNull:^null becomes notNull:not<null>
    • ?typeName - type-level macro for variant<undefined, null, typeName>
      • Example: maybeTuple:?tuple<*, *, *> becomes maybeTuple:variant<undefined, null, tuple<*, *, *>>
    • (types => types => ...) - signature-level macro for function<types => types => ...>
      • Example: (string => int => null) becomes function<string => int => null>

    Dependent types

    Types can be named and dependencies can be declared between two arguments in the same call. Signet currently does not have the means to verify dependent types across function calls.

    Example for a range function might look like the following:

    start < end :: start:int, end:int, increment:[leftBoundedInt<1>] => array<int>

    Built in type operations are as follows:

    • number:
      • = (value equality)
      • != (value inequality)
      • < (A less than B)
      • > (A greater than B)
      • <= (A less than or equal to B)
      • >= (A greater than or equal to B)
    • string:
      • = (value equality)
      • != (value inequality)
      • #= (length equality)
      • #< (A.length less than B.length)
      • #> (A.length greater than B.length)
    • array
      • #= (length equality)
      • #< (A.length less than B.length)
      • #> (A.length greater than B.length)
    • object:
      • = (property equality)
      • !=(property inequality)
      • :> (property superset)
      • :< (property subset)
      • := (property congruence -- same property names, potentially different values)
      • :!= (property incongruence -- different property names)
    • variant:
      • =: (same type)
      • <: (subtype)
      • >: (supertype)

    Signet behaviors

    Signet can be used two different ways to sign your functions, as a function wrapper or as a decoration of your function. Below are examples of the two use cases:

    Function wrapper style:

        const add = signet.sign('number, number => number',
        function add (a, b) {
            return a + b;
        });
        
        console.log(add.signature); // number, number => number
    

    Function decoration style:

        signet.sign('number, number => number`, add);
        function add (a, b) {
            return a + b;
        }
    

    Example of curried function type annotation:

        const curriedAdd = signet.sign(
            'number => number => number',
            (a) => (b) => a + b
        );
    

    Signet signatures are immutable, which means once they are declared, they cannot be tampered with. This adds a guarantee to the stability of your in-code documentation. Let's take a look:

        const add = signet.sign(
            'number, number => number',
            (a, b) => a + b
        );
        
        add.signature = 'I am trying to change the signature property';
        console.log(add.signature); // number, number => number
    

    Arguments can be enforced directly with enforceArguments in places where enforce is inappropriate or not feasible for use:

        const enforceAddArgs = signet.enforceArguments(['a: number', 'b: number']);
    
        function verifiedAdd (a, b) {
            enforceAddArgs(arguments);
            return a + b;
        }
        
        signet.sign('number, number => number', verifiedAdd);
    

    Functions can be signed and verified all in one call with the enforce function:

        const enforcedAdd = signet.enforce(
            'a:number, b:number => sum:number',
            (a, b) => a + b
        );
    

    Curried functions are also fully enforced all the way down:

        const curriedAdd = signet.enforce(
            'a:number => b:number => sum:number',
            (a) => (b) => a + b
        );
        
        curriedAdd(1)('foo'); // Throws -- Expected type number, but got string
    

    Types and subtypes

    New types can be added by using the extend function with a key and a predicate function describing the behavior of the data type

        signet.extend('foo', (value) => value !== 'bar');
        signet.isTypeOf('foo')('baz'); // false
    

    Subtypes can be added by using the subtype function. This is particularly useful for defining and using business types or defining restricted types.

        signet.subtype('number')('int', (value) => Math.floor(value) === value && value !== infinity);
        
        const enforcedIntAdd = signet.enforce(
            'a:int, b:int => sum:int',
            (a, b) => a + b
        );
        
        enforcedIntAdd(1.2, 5); // Throws error
        enforcedIntAdd(99, 3000); // 3099
    

    Using secondary type information for type constructor definition. Any secondary type strings for type constructors will be automatically split on ';' or ',' to allow for multiple type arguments.

        signet.subtype('array')('triple` function (value) {
            return isTypeOf(typeObj.valueType[0])(value[0]) &&
                isTypeOf(typeObj.valueType[1])(value[1]) &&
                isTypeOf(typeObj.valueType[2])(value[2]);
        });
    
        const multiplyTripleBy5 = signet.enforce(
            'triple<int; int; int> => triple<int; int; int>', 
            (values) => values.map(x => x * 5)
        );
        
        multiplyTripleBy5([1, 2]); // Throws error
        multiplyTripleBy5([1, 2, 3]); // [5, 10, 15]
    

    Types can be aliased using the alias function. This allows the programmer to define and declare a custom type based on existing types or a particular implementation on constructed types.

        signet.alias('R3Point', 'triple<number; number; number>');
        signet.alias('R3Matrix', 'triple<R3Point; R3Point; R3Point>')
        
        signet.isTypeOf('R3Point')([1, 2, 3]); // true
        signet.isTypeOf('R3Point')([1, 'foo', 3]); // false
        
        // Matrix in R3:
        signet.isTypeOf('R3Matrix')([[1, 2, 3], [4, 5, 6], [7, 8, 9]]); // true
    

    Direct type checking

    Types can be checked from outside of a function call with isTypeOf. The isTypeOf function is curried, so a specific type check can be reused without recomputing the type object definition:

        const isInt = signet.isTypeOf('int');
        isInt(7); // true
        isInt(83.7); // false
        
        const isRanged3to4 = signet.isTypeOf('ranged<3;4>');
        isRanged3to4(3.72); // true
        isRanged3to4(4000); // false
    

    Types can also be checked as a passthrough using signet.verifyValueType(). This is especially useful for verifying the type of a value as it is being assigned to a variable.

        function anIntegerOperation (myValue) {
            const myInt = signet.verifyValueType('int')(myValue);
    
            return doSomeIntegerThing(myInt);
        }
    
        // OR
    
        const verifyIntValue = signet.verifyValueType('int');
    
        function anIntegerOperation (myValue) {
            const myInt = verifyIntValue(myValue);
    
            return doSomeIntegerThing(myInt);
        }

    Object duck typing

    Duck typing functions can be created using the duckTypeFactory function. This means, if an object type depends on extant properties with correct types, it can be predefined with an object type definition.

        const myObjDef = { foo: 'string', bar: 'array' };
        const checkMyObj = signet.duckTypeFactory(myObjDef);
    
        signet.subtype('object')('myObj', checkMyObj);
    
        signet.isTypeOf('myObj')({ foo: 'testing', bar: [] }); // true
        signet.isTypeOf('myObj')({ foo: 'testing' }); // false
        signet.isTypeOf('myObj')({ foo: 42, bar: [] }); // false
    

    Building Recursive Types

    Though recursive types such as trees and linked lists can be created with the signet type definition method, but this requires a fair amount of recursive thinking. Instead, Signet provides a means for simply creating recursive types without the recursive thinking.

    Here is an example of creating a linked list type function:

        const isListNode = signet.duckTypeFactory({
            value: 'int',
            next: 'composite<not<array>, object>'
        });
    
        const iterableFactory = signet.iterateOn('next');
        const isIntList = signet.recursiveTypeFactory(iterableFactory, isListNode);
    

    To create a more complex type like a binary tree, we would do the following:

        const isBinaryTreeNode = signet.recursiveTypeFactory('binaryTreeNode', {
            value: 'int',
            left: 'composite<^array, object>',
            right: 'composite<^array, object>',
        });
    
        const isNodeOrNull = (node) => node === null || isBinaryTreeNode(node);
    
        function isOrderedNode (node) {
            return isBinaryTreeNode(node)
                || isNodeOrNull(node.left)
                || isNodeOrNull(node.right)
                || (node.value > node.left 
                    && node.value <= node.right);
        }
    
        signet.subtype('object')('orderedBinaryTreeNode', isOrderedNode);
    
        function iteratorFactory (value) {
            var iterable = [];
    
            iterable = value.left !== null ? iterable.concat([value.left]) : iterable;
            iterable = value.right !== null ? iterable.concat([value.right]) : iterable;
    
            return signet.iterateOnArray(iterable);
        }
    
        signet.defineRecursiveType('orderedBinaryTree', iteratorFactory, 'binaryTreeNode');
    

    Type Chain Information

    Signet supports accessing a type's inheritance chain. This means, if you want to know what a type does, you can review the chain and get a rich understanding of the ancestors which make up the particular type.

        signet.typeChain('array'); // * -> object -> array
        signet.typeChain('tuple'); // * -> object -> array -> tuple
    

    Type-Level Macros

    Signet supports the creation of type-level macros to handle special cases where a type definition might need some pre-processing before being processed. This is especially useful if you want to create a type name which contains special characters. The example from Signet itself is the () type.

        const starTypeDef = parser.parseType('*');
    
        parser.registerTypeLevelMacro('()', function () { return starTypeDef; });
    
        signet.enforce('() => undefined', function () {})();
        signet.isType('()'); // false
    

    Type Constructor Arity Declaration

    You can declare the number of arguments a type constructor requires (the arity of your type constructor) with curly-brace annotation at definition time. Following are examples of declaring type constructor arity with enforce, subtype and alias:

        // variant requires at least 1 argument, though more are acceptable
        extend('variant{1,}', isVariant, optionsToFunctions); 
    
        // array accepts up to 1 argument
        subtype('object')('array{0,1}', checkArray);
    
        // leftBounded requires exactly 1 argument
        alias('leftBounded{1}', 'bounded<_, Infinity>')
    

    Signet API

    • alias: aliasName != typeString :: aliasName:string, typeString:string => undefined
    • buildInputErrorMessage: validationResult:array, args:array, signatureTree:array, functionName:string => string
    • buildOutputErrorMessage: validationResult:array, args:array, signatureTree:array, functionName:string => string
    • classTypeFactory: class:function, otherProps:[composite<not<null>, object>] => function
    • defineClassType: class:function, otherProps:[composite<not<null>, object>] => function
    • defineDuckType: typeName:string, duckTypeDef:object => undefined
    • defineExactDuckType: typeName:string, duckTypeDef:object => undefined
    • defineDependentOperatorOn: typeName:string => operator:string, operatorCheck:function => undefined
    • defineRecursiveType: typeName:string, iteratorFactory:function, nodeType:type, typePreprocessor:[function] => undefined
    • duckTypeFactory: duckTypeDef:object => function
    • enforce: signature:string, functionToEnforce:function, options:[object] => function
      • currently supported options:
        • inputErrorBuilder: [validationResult:array], [args:array], [signatureTree:array], [functionName:string] => 'string'
        • outputErrorBuilder: [validationResult:array], [args:array], [signatureTree:array], [functionName:string] => 'string'
    • enforceArguments: array<string> => arguments => undefined
    • extend: typeName:string, typeCheck:function, preprocessor:[function] => undefined
    • exactDuckTypeFactory: duckTypeDef:object => function
    • isRegisteredDuckType: typeName:string => boolean
    • isSubtypeOf: rootTypeName:string => typeNameUnderTest:string => boolean
    • isType: typeName:string => boolean
    • isTypeOf: typeToCheck:type => value:* => boolean
    • iterateOn: propertyKey:string => value:* => undefined => *
    • iterateOnArray: iterationArray:array => undefined => *
    • recursiveTypeFactory: iteratorFactory:function, nodeType:type => valueToCheck:* => boolean
    • registerTypeLevelMacro: macro:function => undefined
    • reportDuckTypeErrors: duckTypeName:string => valueToCheck:object => array<tuple<string; string; *>>
    • sign: signature:string, functionToSign:function => function
    • subtype: rootTypeName:string => subtypeName:string, subtypeCheck:function, preprocessor:[function] => undefined
    • typeChain: typeName:string => string
    • verify: signedFunctionToVerify:function, functionArguments:arguments => undefined
      • Throws on error
    • verifyValueType: typeToCheck:type => value:* => result:*
      • Throws on error
    • whichType: typeNames:array<string> => value:* => variant<string; null>
    • whichVariantType: variantString:string => value:* => variant<string; null>

    Change Log

    6.7.0

    • Added class types for better TypeScript interop with the following methods:
      • defineClassType
      • classTypeFactory
    • Added enforceArguments method for situations where enforce and sign are either inappropriate or not feasible for use

    6.6.0

    • Introduced decimalPrecision type to declare required or returned decimal precision

    6.5.0

    • Added verifyValueType: provides inline value verification for assignments and other non-function-level enforcement

    6.0.0

    • Added enforcement around function type signatures for higher-order functions

    4.0.0

    • Changed bounded type to polymorphic type on strings, arrays and numbers (and associated proper subtypes)
    • Introduced sequence, monotone, increasing and decreasing types

    3.15.0

    • Added isRegisteredDuckType

    3.14.0

    • Updated duck type error reporter to resolve type-level macros to their proper types

    3.13.0

    • Added ^typeName macro for not<typeName>

    3.12.0

    • Added !* macro for not<variant<undefined, null>>

    3.11.0

    • Added support for declaring type constructor arity

    3.10.0

    • Added not type negation and composite type composition

    3.9.0

    • Added #=, #< and #> operators for string and array

    3.8.0

    • Added exact duck types to limit types to only those specified

    3.7.0

    • Added nested function type declarations

    3.6.0

    • Added partial application to type constructors in type aliasing

    3.5.0

    • Exposed preprocessor option for extend and subtype functions

    3.4.0

    • Updated error messages to include function name as available

    3.3.0

    • Added object context preservation to ensure constructors and methods can safely be decorated and standard bind, call and apply actions work as expected

    3.2.0

    • Added support for multiple dependent type expressions

    3.1.0

    • Extended reportDuckTypeErrors to perform a recursive search through an object when possible

    3.0.0

    • Added escape character % to parser to allow for special characters in type arguments

    2.0.0

    • Moved to macros which operate directly on uncompiled strings

    1.10.0

    • Introduced type-level macros

    1.9.0

    • Enhanced 'type' type check to verify type is registered

    1.6.0

    • Added new types:
      • leftBounded<min:number> -- value must be greater than or equal to min
      • rightBounded<max:number> -- value must be less than or equal to max
      • leftBoundedInt<min:number> -- value must be greater than or equal to min
      • rightBoundedInt<max:number> -- value must be less than or equal to max

    1.5.0

    • Added unorderedProduct -- like tuple but values can be in any order

    Breaking Changes

    2.0.0

    • Moved to macros which operate directly on uncompiled strings

    1.0.0

    • No-argument type '()' no longer supported
    • TaggedUnion deprecated in preference for 'variant'

    0.18.0

    • Function signatures now verify parameter length against total length and length of required paramters.

    0.16.x

    • Signet and SignetTypes are now factories in node space to ensure types are encapsulated only in local module.

    0.9.x

    • valueType is now an array instead of a string; any type constructor definitions relying on a string will need updating

    0.4.x

    • Any top-level types will now cause an error if they are not part of the core Javascript types or in the following list:
      • ()
      • any
      • array
      • boolean
      • function
      • number
      • object
      • string
      • symbol

    Install

    npm i signet

    DownloadsWeekly Downloads

    134

    Version

    7.1.2

    License

    MPL-2.0

    Unpacked Size

    382 kB

    Total Files

    33

    Last publish

    Collaborators

    • cmstead