@vicin/sigil
v3.4.0
Published
Sigil gives you the power of safe cross-bundle class instances checks and simple class nominal typing if needed
Maintainers
Readme
Sigil
- 🎉 v3.0.0 is out! Happy coding! 😄💻
- 📄 Changelog: CHANGELOG.md
Sigil gives you the power of safe cross-bundle class instances checks and simple class nominal typing if needed.
Features
- ✅ Drop-in
instanceofreplacement that works across bundles, HMR and monorepos, and adds an exact-class-instance check - ✅ Simple nominal typing with just one line of code for each class (e.g.,
UserIdvs.PostId) - ✅ Tiny less than 1.6 KB minified and brotlied measured using size-limit
- ✅ Performant as native instanceof but with guaranteed checks
- ✅ Test coverage is 100% to ensure that runtime remains consistent and predictable
- ✅ Safe with strict rules to ensure uniqueness of labels, if duplicate label is passed error is thrown immediately
Table of contents
- Quick start
- Core concepts
- API reference
- Options & configuration
- Minimal mode
- Strict mode
- Hot module reload
- Edge cases
- Benchmarks
- Bundle Size
- Tests
- Contributing
- License
- Author
Quick start
Install
npm install @vicin/sigil
# or
yarn add @vicin/sigil
# or
pnpm add @vicin/sigilRequires TypeScript 5.0+ for decorators; attach functions work on older versions. Node.js 18+ recommended.
No tsconfig changes are needed as we use Stage 3 decorators which are supported by default in TypeScript 5.0+
Basic usage
Opt into Sigil
Use the Sigil base class or the Sigilify mixin to opt a class into the Sigil runtime contract.
import { Sigil, Sigilify } from '@vicin/sigil';
// Using the pre-sigilified base class:
class User extends Sigil {}
// Or use Sigilify when you want an ad-hoc class:
const MyClass = Sigilify(class {}, '@myorg/mypkg.MyClass');If your class is marked with abstract:
import { Sigil, SigilifyAbstract } from '@vicin/sigil';
abstract class User extends Sigil {}
const MyClass = SigilifyAbstract(abstract class {}, '@myorg/mypkg.MyClass');Extend Sigil classes
After opting into the Sigil contract, labels are passed to child classes to uniquely identify them, they can be supplied using two patterns:
Decorator pattern
Apply a label with the @AttachSigil decorator:
import { Sigil, AttachSigil } from '@vicin/sigil';
@AttachSigil('@myorg/mypkg.User')
class User extends Sigil {}Function pattern
Apply a label using attachSigil function:
import { Sigil, attachSigil } from '@vicin/sigil';
class User extends Sigil {}
attachSigil(User, '@myorg/mypkg.User');Note: Function pattern is susceptible to some Edge case subtle pitfalls if not used appropriately, so we advise to use decorator pattern
Migration
Migrating old code into Sigil can be done with extra couple lines of code only:
- Pass your base class to
Sigilifymixin:
import { Sigilify } from '@vicin/sigil';
const MySigilBaseClass = Sigilify(MyBaseClass);- Or extend it with
Sigil:
import { Sigil } from '@vicin/sigil';
class MyBaseClass extends Sigil {} // <-- add 'extends Sigil' hereCongratulations — you’ve opted into Sigil and you can start replacing instanceof with isOfType() / isExactType(), however there is more to add to your system, check Core concepts for more.
Core concepts
Terminology
- Label: An identity (string) such as
@scope/pkg.ClassName, must be unique for eachSigilclass otherwise error is thrown. - EffectiveLabel: A human-readable (string) such as
@scope/pkg.ClassName, if no label is passed it inherit the last defined label. - isOfType: Takes object argument and check if this object is an instance of calling class or it's children. Can be called from class instances as well.
- isExactType: Takes object argument and check if this object is an instance of calling class only. Can be called from class instances as well.
- [sigil]: TypeScript symbol marker for nominal types.
Purpose and Origins
Sigil addresses issues in large monorepos, HMR:
- Unreliable
instanceof: Bundling cause class redefinitions, breaking checks.
// Can be broken in monorepo or HMR set-ups
if (obj instanceof User) { ... }
// With Sigil
if (User.isOfType(obj)) { ... } // This still works even if User was bundled twice.
if (User.isExactType(obj)) { ... } // Or check for exactly same constructor not its childrenAlso by utilizing unique passed labels it solves another problem in Domain-Driven Design (DDD):
- Manual Branding Overhead: Custom identifiers lead to boilerplate and maintenance issues,
Sigiladds reliable inheritance-aware nominal branding with just one line of code.
import { sigil } from '@vicin/sigil';
class User extends Sigil {
declare [sigil]: ExtendSigil<'User', Sigil>; // <-- Update nominal brand with this line
}
type test1 = User extends Sigil ? true : false; // true
type test2 = Sigil extends User ? true : false; // falseImplementation Mechanics
- Runtime Contract: Established via extending
Sigilor usingSigilifymixin. - Update metadata: With each new child, use decorator (
AttachSigil) or function (attachSigil) to attach run-time metadata, also useExtendSigilon[sigil]field to update nominal type.
import { Sigil, AttachSigil, sigil, ExtendSigil } from '@vicin/sigil';
@AttachSigil('@scope/package.MyClass') // <-- Run-time values update
class MyClass extends Sigil {
declare [sigil]: ExtendSigil<'MyClass', Sigil>; // <-- compile-time type update
}You can avoid decorators and use normal functions if needed:
import { Sigil, attachSigil, sigil, ExtendSigil } from '@vicin/sigil';
class MyClass extends Sigil {
declare [sigil]: ExtendSigil<'MyClass', Sigil>;
}
attachSigil(MyClass, '@scope/package.MyClass');Example
import { Sigil, AttachSigil } from '@vicin/sigil';
@AttachSigil('@myorg/User')
class User extends Sigil {
declare [sigil]: ExtendSigil<'User', Sigil>;
}
@AttachSigil('@myorg/Admin')
class Admin extends User {
declare [sigil]: ExtendSigil<'Admin', User>;
}
const admin = new Admin();
const user = new User();
// Instanceof like behavior
console.log(Admin.isOfType(admin)); // true
console.log(Admin.isOfType(user)); // false
console.log(User.isOfType(admin)); // true
console.log(User.isOfType(user)); // true
// Exact checks
console.log(Admin.isExactType(admin)); // true
console.log(Admin.isExactType(user)); // false
console.log(User.isExactType(user)); // true
console.log(User.isExactType(admin)); // false (Admin is child indeed but this checks for user specifically)
// Can use checks from instances
console.log(admin.isOfType(user)); // false
console.log(user.isOfType(admin)); // true
console.log(admin.isExactType(user)); // false
console.log(user.isExactType(admin)); // false
// Type checks are nominal
type test1 = Admin extends User ? true : false; // true
type test2 = User extends Admin ? true : false; // false
// Passed label must be unique (enforced by Sigil) so can be used as stable Id for class
// Also 'SigilLabelLineage' is useful for logging & debugging
console.log(Admin.SigilLabel); // '@myorg/Admin'
console.log(Admin.SigilEffectiveLabel); // '@myorg/Admin'
console.log(Admin.SigilLabelLineage); // ['Sigil', '@myorg/User', '@myorg/Admin']
console.log(admin.getSigilLabel()); // '@myorg/Admin'
console.log(admin.getSigilEffectiveLabel()); // '@myorg/Admin'
console.log(admin.getSigilLabelLineage()); // ['Sigil', '@myorg/User', '@myorg/Admin']Errors & throws
Run-time errors that can be thrown by Sigil:
Double Sigilify
class A {}
Sigilify(Sigilify(A, 'A'), 'B'); // Throws: [Sigil Error] Class 'Sigilified' with label 'A' is already sigilified
@AttachSigil('B')
@AttachSigil('A')
class A extends Sigil {} // Throws: [Sigil Error] Class 'A' with label 'A' is already sigilified
class A extends Sigil {}
attachSigil(attachSigil(A, 'A'), 'B'); // Throws: [Sigil Error] Class 'A' with label 'A' is already sigilified@AttachSigil() / attachSigil() on non-Sigil class
@AttachSigil('A') // Throws: [Sigil Error] 'AttachSigil' decorator accept only Sigil classes but used on class 'A'
class A {}
attachSigil(class A {}); // Throws: [Sigil Error] 'AttachSigil' function accept only Sigil classes but used on class 'A'No label is passed with autofillLabels: false
updateSigilOptions({ autofillLabels: false });
class A extends Sigil {}
new A(); // Throws: [Sigil Error] Class 'A' is not sigilified, Make sure to sigilify all Sigil classes or set 'autofillLabels' to 'true'Same label is passed twice to Sigil
@AttachSigil('Label')
class A extends Sigil {}
@AttachSigil('Label')
class B extends Sigil {} // Throws: [Sigil Error] Passed label 'Label' to class 'B' is re-used, passed labels must be uniqueInvalid label format
updateSigilOptions({ labelValidation: RECOMMENDED_LABEL_REGEX });
@AttachSigil('InvalidLabel')
class A extends Sigil {} // Throws: [Sigil Error] Invalid Sigil label 'InvalidLabel'. Make sure that supplied label matches validation regex or functionUsing '@Sigil-auto' prefix
@AttachSigil('@Sigil-auto:label')
class X extends Sigil {} // Throws: '@Sigil-auto' is a prefix reserved by the libraryInvalid options passed to updateOptions
updateSigilOptions({ autofillLabels: {} as any }); // Throws: 'updateSigilOptions.autofillLabels' must be boolean
updateSigilOptions({ labelValidation: 123 as any }); // Throws: 'updateSigilOptions.labelValidation' must be null, function or RegExp
updateSigilOptions({ skipLabelUniquenessCheck: 'str' as any }); // Throws: 'updateSigilOptions.skipLabelUniquenessCheck' must be booleanAPI reference
Primary Exports
Mixins:
Sigilify(Base, label, opts?)SigilifyAbstract(Base, label, opts?)
Classes:
SigilSigilError
Decorator:
AttachSigil(label, opts?)
Attach function:
attachSigil(Class, label, opts?)
Helpers:
isSigilCtor(ctor)isSigilInstance(inst)getSigilLabels()
Options:
updateSigilOptions(opts)RECOMMENDED_LABEL_REGEX
Types:
ISigil<Label, ParentSigil?>ISigilStatic<Label, ParentSigil?>ISigilInstance<Label, ParentSigil?>SigilOf<T>ExtendSigil<Label, Parent>GetPrototype<Class>SigilOptions
Key helpers (runtime)
Sigil: a minimal sigilified base class you can extend from.SigilError: anErrorclass decorated with aSigilso it can be identified at runtime.Sigilify(Base, label, opts?): mixin function that returns a new constructor withSigiltypes and instance helpers.SigilifyAbstract(Base, label, opts?): Same asSigilifybut for abstract classes.AttachSigil(label, opts?): class decorator that attachesSigilmetadata at declaration time.attachSigil(Class, label, opts?): function that validates and decorates an existing class constructor.isSigilCtor(value):trueifvalueis aSigilconstructor.isSigilInstance(value):trueifvalueis an instance of aSigilconstructor.getSigilLabels(): GetSigillabels registered.updateSigilOptions(opts): change global runtime options ofSigillibrary (e.g.,autofillLabels).RECOMMENDED_LABEL_REGEX: regex that ensures structure of@scope/package.ClassNameto all labels, it's advised to use it as yourSigilOptions.labelValidation
Instance & static helpers provided by Sigilified constructors
When a constructor is sigilified it will expose the following static getters/methods:
SigilLabel— the identity label string.SigilEffectiveLabel— the human label string.SigilLabelLineage— readonly array of labels representing parent → child for debugging.isOfType(other)— check if other is an instance of this constructor or its children.isExactType(other)— check if other is an instance exactly this constructor.
Instances of sigilified classes expose instance helpers:
getSigilLabel()— returns the identity label.getSigilEffectiveLabel()— returns the human label.getSigilLabelLineage()— returns lineage array.isOfType(other)— check if other is an instance of the same class or its children as this.isExactType(other)— check if other is an instance exactly the same constructor.
Options & configuration
Customize behavior globally at startup:
import { updateSigilOptions } from '@vicin/sigil';
updateSigilOptions({
autofillLabels: true, // Automatically label unlabeled subclasses
labelValidation: null, // Function or regex, Enforce label format
skipLabelUniquenessCheck: false, // Skip uniqueness check for labels, should be used in HMR set-ups only
});Values defined in previous example are defaults, per-class overrides available in mixin and attach function / decorator.
Minimal mode
By default Sigil works with minimal mode, You can ignore all decorators and functions and just make base class extend Sigil:
import { Sigil, updateSigilOptions } from '@vicin/sigil';
// No decorators or functions needed to use 'isOfType' ('instanceof' replacement)
class A extends Sigil {}
class B extends A {}
class C extends B {}Strict mode
If you want to enforce passing a label to every class defined in your codebase, you can set autofillLabels to false at the start of app:
import { updateSigilOptions } from '@vicin/sigil';
updateSigilOptions({ autofillLabels: false });Now if you forgot to pass a label error is thrown at the moment you create class instance or use any of Sigil methods.
Hot module reload
HMR can cause class re-definitions, which will throw in default Sigil set-up as same label will be passed multiple times triggering duplicate label error.
To avoid this you can set global options to skip label uniqueness check at the start of app:
import { updateSigilOptions } from '@vicin/sigil';
updateSigilOptions({ skipLabelUniquenessCheck: true });But this can cause unexpected behavior if same label is used for two different classes as checks are disabled globally. If you need more strict mode you can pass this options to the re-loaded class only:
@AttachSigil('HmrClassLabel', { skipLabelUniquenessCheck: true })
class HmrClass extends Sigil {}With this approach skipLabelUniquenessCheck affects only HmrClass, and if HmrClassLabel or any other label is re-used error is thrown immediately
Edge cases
Accessing Sigil metadata before running 'attachSigil' function
If you didn't make sure that attachSigil runs right after class declaration and used one of Sigil methods this will occur:
class A extends Sigil {}
console.log(A.SigilLabel); // returns auto-generated label (e.g. @Sigil-auto:A:6:a3f15bhl) or throws in strict mode
attachSigil(A, 'A');
console.log(A.SigilLabel); // ATo avoid this bug entirely you can use the return of attachSigil in your code so you are enforced to respect order:
class _A extends Sigil {}
const A = attachSigil(_A, 'A');
type A = InstanceType<typeof A>;
console.log(A.SigilLabel); // ANote that you can't use InstanceType on private or protected classes, however you can use GetPrototype<T> in such cases.
Static blocks & IIFE static initializer
Decorators ensure that metadata is appended before static blocks or IIFE static initializers, however attachSigil function runs after them so accessing label inside them will return auto-generated label or throw:
class A extends Sigil {
static IIFE = (() => {
const label = A.SigilLabel; // returns auto-generated label (e.g. @Sigil-auto:A:6:a3f15bhl) or throws in strict mode
})();
static {
const label = this.SigilLabel; // returns auto-generated label (e.g. @Sigil-auto:A:6:a3f15bhl) or throws in strict mode
}
}
attachSigil(A, 'A');This behavior can't be avoided, so make sure not to call any Sigil method inside them or move to decorators (@AttachSigil)
Benchmarks
Sigil is built for real-world performance. Below are the latest micro-benchmark results (run on Node.js v20.12.0).
Running Tests
To run benchmarks on your machine fetch source code from github then:
npm install
npm run bench1. Runtime Type Checking
| Depth | instanceof (per op) | isOfType (ctor) | isOfType (instance) | isExactType (ctor) | isExactType (instance) |
| ----- | --------------------- | ----------------- | --------------------- | -------------------- | ------------------------ |
| 0 | 0.000010 ms | 0.000025 ms | 0.000010 ms | 0.000027 ms | 0.000012 ms |
| 3 | 0.000032 ms | 0.000045 ms | 0.000027 ms | 0.000038 ms | 0.000025 ms |
| 5 | 0.000034 ms | 0.000046 ms | 0.000028 ms | 0.000037 ms | 0.000026 ms |
| 10 | 0.000044 ms | 0.000045 ms | 0.000029 ms | 0.000038 ms | 0.000027 ms |
| 15 | 0.000058 ms | 0.000063 ms | 0.000051 ms | 0.000069 ms | 0.000053 ms |
Key takeaway:
isOfType&isExactTypehas practically the same performance as nativeinstanceof, slightly slower on static calls and slightly faster on the instance side.
2. Class Definition & Instance Creation
| Scenario | Definition (per class) | Instantiation (per instance) | | ------------------------------- | ---------------------- | ---------------------------- | | Empty plain class | 0.0122 ms | 0.00019 ms | | Empty Sigil class | 0.0672 ms | 0.00059 ms | | Small (5 props + 3 methods) | 0.0172 ms | 0.00327 ms | | Large (15 props + 10 methods) | 0.0212 ms | 0.00922 ms | | Large Sigil | 0.0780 ms | 0.01177 ms | | Extended chain depth 5 – plain | 0.0897 ms | 0.01809 ms | | Extended chain depth 5 – Sigil | 0.3978 ms | 0.02020 ms | | Extended chain depth 10 – plain | 0.2042 ms | 0.05759 ms | | Extended chain depth 10 – Sigil | 0.8127 ms | 0.06675 ms |
Key takeaways:
- Class definition is a one-time cost at module load time. Even at depth 10 the cost stays well under 1 ms per class.
- Instance creation adds a small fixed overhead of ~0.4–0.6 µs per object, which becomes completely negligible as your classes grow in size and complexity.
Bundle Size
Less than 1.6 KB (1.52 KB) (minified + Brotli, including all dependencies)
This makes Sigil one of the smallest full-featured solutions for nominal typing + reliable runtime identity.
Running Tests
To verify bundle size fetch source code from github then:
npm install
npm run sizeTests
Reliability is a core pillar of Sigil. The library is backed by a comprehensive suite of unit tests ( 71 total tests ) that cover everything from basic mixins to edge cases.
Coverage Status
We maintain 100% test coverage across the entire codebase to ensure that runtime metadata remains consistent and predictable.
| Metric | Score | | ------ | ----- | | Stmts | 100% | | Branch | 100% | | Funcs | 100% | | Lines | 100% |
Key Test Areas
- Mixins, Attach function & decorator: Validating
Sigilify,AttachSigilandattachSigilbehaviors. - Sigil methods: Ensuring
Sigilclass methods (e.g.SigilLabel,getSigilLabel) work as expected. - Lazy Evaluation: Ensuring metadata is attached before being accessed via
Sigilmethods even when no attach function or decorator is used. - Lineage: Verifying that
isOfTypeandisExactTypework across complex inheritance chains. - Error Handling: Strict validation for all errors and throws.
- Edge cases: Known edge cases.
Running Tests
To run the test suite locally and generate a coverage report, fetch source code from github then:
npm install
npm run test:unitContributing
Any contributions you make are greatly appreciated.
Please see our CONTRIBUTING.md for details on our code of conduct and the process for submitting pull requests.
Reporting bugs
If you encounter a bug:
- Check existing issues first
- Open a new issue with:
- Minimal reproduction
- Expected vs actual behavior
- Environment (Node, TS version)
Bug reports help improve Sigil — thank you! 🙏
License
Distributed under the MIT License. See LICENSE for more information.
Author
Built with ❤️ by Ziad Taha.
- GitHub: @ZiadTaha62
- NPM: @ziadtaha62
- Vicin: @vicin
