@vicin/sigil
v4.0.2
Published
Sigil, bulletproof class identity for large TypeScript projects
Downloads
90
Maintainers
Readme
Sigil
- 🎉 v4.0.0 is out! Happy coding! 😄💻
- 📄 Changelog: CHANGELOG.md
Sigil — bulletproof class identity for large TypeScript projects.
Reliable instanceof-style checks that survive bundling, HMR, monorepos and realms + exact/subtype discrimination + lightweight nominal typing + and debug/log friendly labels/lineage.
Why Sigil?
- Works when
instanceofbreaks (multi-bundle, HMR, separate realms) - Exact class match (
isExactInstance) — not just "inherits from" - One-line nominal branding (
declare [sigil]: ExtendSigil<…>) - Human-readable class IDs in logs & debugging (
SigilLabel/SigilLineage) - Tiny (~1 kB brotli)
- Fast (near native
instanceofspeed) - 100% test coverage
Limitations
- Identity is explicit and dependent on passed labels, If same label is passed to two different classes
Sigilwill treat them as one - Identity is updated with every new label passed only, If you stopped passing labels to child classes their identity will stop at last passed label
Sigilis not built for security, identity can be forged
Earlier versions of Sigil tried to solve these limitations, however it was not 100% reliable, so we decided to make this package minimal and stable, while adding new features in future package @vicin/sigil-extend
Table of contents
- Quick start
- Core concepts
- API reference
- Options & configuration
- 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');Migration
- 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, now you can start giving your classes identity by using attachSigil or AttachSigil helpers.
Core concepts
Terminology
- Label: An identity (string) such as
@scope/pkg.ClassName, must be unique for eachSigilclass otherwise error is thrown. - isInstance: 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.
- isExactInstance: 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 codebases and monorepos:
- 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.isInstance(obj)) { ... } // This still works even if User was bundled twice.
if (User.isExactInstance(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; // false- Need for stable class id: Most large projects implement their own labels (e.g. to be used in logs)
Implementation 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.isInstance(admin)); // true
console.log(Admin.isInstance(user)); // false
console.log(User.isInstance(admin)); // true
console.log(User.isInstance(user)); // true
// Exact checks
console.log(Admin.isExactInstance(admin)); // true
console.log(Admin.isExactInstance(user)); // false
console.log(User.isExactInstance(user)); // true
console.log(User.isExactInstance(admin)); // false (Admin is child indeed but this checks for user specifically)
// Can use checks from instances
console.log(admin.isInstance(user)); // false
console.log(user.isInstance(admin)); // true
console.log(admin.isExactInstance(user)); // false
console.log(user.isExactInstance(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.SigilLabelLineage); // ['Sigil', '@myorg/User', '@myorg/Admin']
console.log(admin.SigilLabel); // '@myorg/Admin'
console.log(admin.SigilLabelLineage); // ['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'Invalid 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 functionAttach sigil to parent after child
class Parent extends Sigil {}
class Child extends Parent {}
attachSigil(Child, 'Child');
attachSigil(Parent, 'Parent'); // Throws: [Sigil Error] Class 'Parent' with label 'Sigil' is already sigilifiedAPI 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)hasOwnSigil(ctor)
Options:
updateSigilOptions(opts)RECOMMENDED_LABEL_REGEX
Types:
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.hasOwnSigil(ctor):trueif new sigil label is attached toctorupdateSigilOptions(opts): change global runtime options ofSigillibrary (e.g.,labelValidation).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.SigilLabelLineage— readonly array of labels representing parent → child for debugging.hasOwnSigil— check if new sigil label is attached to this class.isInstance(other)— check if other is an instance of this constructor or its children.isExactInstance(other)— check if other is an instance exactly this constructor.
Instances of sigilified classes expose instance helpers:
SigilLabel— the identity label string.SigilLabelLineage— readonly array of labels representing parent → child for debugging.hasOwnSigil— check if new sigil label is attached to this class.isInstance(other)— check if other is an instance of the same class or its children as this.isExactInstance(other)— check if other is an instance exactly the same constructor.
Options & configuration
Customize behavior globally at startup:
import { updateSigilOptions } from '@vicin/sigil';
updateSigilOptions({
labelValidation: null, // Function or regex, Enforce label format
});Values defined in previous example are defaults, per-class overrides available in mixin and attach function / decorator.
Edge cases
Attach sigil to parent after child
When attaching Sigil labels to classes order from parent -> child must be respected
class Parent extends Sigil {}
class Child extends Parent {}
attachSigil(Child, 'Child');
attachSigil(Parent, 'Parent'); // Throws: [Sigil Error] Class 'Parent' with label 'Sigil' is already sigilifiedAccessing 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 label of the parent (Sigil in our case)
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
Stage 3 decorators (AttachSigil) and function (attachSigil) runs after IIFE and static blocks, so accessing Sigil metadata inside them should be avoided
@AttachSigil('A')
class A extends Sigil {
static IIFE = (() => {
const label = A.SigilLabel; // returns label of the parent (Sigil in our case)
})();
static {
const label = this.SigilLabel; // returns label of the parent (Sigil in our case)
}
}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:
pnpm install
pnpm run bench --filter @vicin/sigil1. Runtime Type Checking
| Test Name | Ops/sec (hz) | Mean (ms) | p99 (ms) | RME | Samples | | -------------------------- | ------------ | --------- | -------- | ------ | --------- | | Depth 0 | | | | | | | instanceof | 11,986,628 | 0.0001 | 0.0002 | ±0.58% | 5,993,314 | | isInstance (Ctor) | 8,848,040 | 0.0001 | 0.0003 | ±0.63% | 4,424,020 | | isInstance (instance) | 9,246,878 | 0.0001 | 0.0002 | ±0.86% | 4,623,440 | | isExactInstance (Ctor) | 5,114,916 | 0.0002 | 0.0003 | ±0.44% | 2,557,459 | | isExactInstance (instance) | 5,553,370 | 0.0002 | 0.0003 | ±0.41% | 2,776,686 | | Depth 5 | | | | | | | instanceof | 8,399,150 | 0.0001 | 0.0002 | ±0.48% | 4,199,651 | | isInstance (Ctor) | 7,385,890 | 0.0001 | 0.0003 | ±2.01% | 3,692,945 | | isInstance (instance) | 7,862,474 | 0.0001 | 0.0003 | ±0.77% | 3,931,238 | | isExactInstance (Ctor) | 4,433,700 | 0.0002 | 0.0004 | ±0.64% | 2,216,851 | | isExactInstance (instance) | 4,898,498 | 0.0002 | 0.0004 | ±0.34% | 2,449,249 | | Depth 10 | | | | | | | instanceof | 7,144,486 | 0.0001 | 0.0003 | ±0.63% | 3,572,243 | | isInstance (Ctor) | 7,165,920 | 0.0001 | 0.0003 | ±0.49% | 3,582,960 | | isInstance (instance) | 7,834,056 | 0.0001 | 0.0003 | ±1.16% | 3,917,029 | | isExactInstance (Ctor) | 4,292,578 | 0.0002 | 0.0004 | ±0.34% | 2,146,290 | | isExactInstance (instance) | 5,020,923 | 0.0002 | 0.0004 | ±0.28% | 2,510,462 | | Depth 15 | | | | | | | instanceof | 7,116,266 | 0.0001 | 0.0002 | ±0.38% | 3,558,134 | | isInstance (Ctor) | 6,308,498 | 0.0002 | 0.0003 | ±0.41% | 3,154,249 | | isInstance (instance) | 6,403,126 | 0.0002 | 0.0003 | ±0.70% | 3,201,564 | | isExactInstance (Ctor) | 3,678,280 | 0.0003 | 0.0005 | ±0.37% | 1,839,141 | | isExactInstance (instance) | 3,753,618 | 0.0003 | 0.0005 | ±0.39% | 1,876,810 |
Key takeaway:
isInstanceEfficiency: Demonstrates high parity with native instanceof, maintaining a minimal performance delta (approx. 15% overhead).isExactInstanceCost-Benefit: While introducing a ~2x overhead compared to native operations, it remains performant in absolute terms. The throughput cost is a deliberate trade-off for the increased precision required for exact matching.
2. Class Definition & Instance Creation
| Test Name | Ops/sec (hz) | Mean (ms) | p99 (ms) | RME | Samples | | ------------------------------------- | ----------------- | ---------------- | ---------------- | ------- | --------- | | Definition (Module Load Time) | | | | | | | Define Empty (Plain vs Sigil) | 122,640 vs 13,154 | 0.0082 vs 0.0760 | 0.0206 vs 0.1413 | ±11.36% | 6,578 | | Define Small (Plain vs Sigil) | 75,363 vs 12,003 | 0.0133 vs 0.0833 | 0.0267 vs 0.1596 | ±5.98% | 6,002 | | Define Large (Plain vs Sigil) | 53,392 vs 11,978 | 0.0187 vs 0.0835 | 0.0352 vs 0.1471 | ±6.13% | 5,990 | | Define Depth 3 (Plain vs Sigil) | 22,802 vs 5,205 | 0.0439 vs 0.1921 | 0.0850 vs 0.3139 | ±14.68% | 2,603 | | Define Depth 5 (Plain vs Sigil) | 12,003 vs 3,369 | 0.0833 vs 0.2968 | 0.1864 vs 0.5488 | ±7.31% | 1,685 | | Define Depth 10 (Plain vs Sigil) | 5,477 vs 1,758 | 0.1826 vs 0.5685 | 0.3402 vs 1.1148 | ±9.62% | 880 | | Instantiation (Runtime/Hot Path) | | | | | | | Instantiate Empty (Plain vs Sigil) | 12.0M vs 11.9M | 0.0001 vs 0.0001 | 0.0002 vs 0.0002 | ±0.76% | 5,999,555 | | Instantiate Small (Plain vs Sigil) | 12.5M vs 11.4M | 0.0001 vs 0.0001 | 0.0001 vs 0.0002 | ±0.89% | 5,739,986 | | Instantiate Large (Plain vs Sigil) | 12.4M vs 12.4M | 0.0001 vs 0.0001 | 0.0002 vs 0.0001 | ±0.74% | 6,205,306 | | Instantiate Depth 3 (Plain vs Sigil) | 12.0M vs 12.1M | 0.0001 vs 0.0001 | 0.0002 vs 0.0002 | ±1.01% | 6,092,517 | | Instantiate Depth 5 (Plain vs Sigil) | 9.09M vs 8.54M | 0.0001 vs 0.0001 | 0.0002 vs 0.0003 | ±2.24% | 4,272,328 | | Instantiate Depth 10 (Plain vs Sigil) | 2.87M vs 1.72M | 0.0003 vs 0.0006 | 0.0008 vs 0.0011 | ±0.75% | 862,564 |
Key takeaways:
Front-Loaded Definition Overhead: Sigil introduces main overhead during the class definition phase (roughly 6x to 10x slower than plain classes). This is a one-time cost per class, typically occurring during module evaluation or registry setup.
Runtime Performance Parity: Once the class is defined, Sigil achieves near-native instantiation throughput. For standard object creation, the delta is negligible, ensuring that the library does not bottleneck high-frequency allocation patterns.
Scale Stability: The overhead of Sigil remains constant regardless of the number of properties or methods added (Small vs. Large). The definition speed for a "Small Sigil" and a "Large Sigil" is nearly identical (~12k hz), suggesting that the setup logic is O(1) relative to class member count.
Bundle Size
Less than 1 kB (997 B) (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:
pnpm install
pnpm run size --filter @vicin/sigilTests
Coverage Status
We maintain 100% test coverage across the entire codebase to ensure that runtime metadata remains consistent and predictable.
| Metric | Score | | ------ | ------ | | Stmts | 96.96% | | Branch | 100% | | Funcs | 100% | | Lines | 100% |
Running Tests
To run the test suite locally and generate a coverage report, fetch source code from github then:
pnpm install
pnpm run test --filter @vicin/sigilContributing
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
