Hegel – An advanced static type checker for JavaScript
hegel.js.org
hegel.js.org
Hegel is basically a less mature version of Flow. I preferred Flow over TS for the longest time, but let's face it – TS won in terms of absolute adoption, with very little prospect for anyone to take over.
Zod is filling a gap in the language - runtime type checking. It is not the only one doing so, but the fact that there are several such solutions clearly indicates that there is a general need for it.
The language doesn’t have, or at least makes not having a primary goal, any runtime. “The” language is JavaScript. TypeScript is just annotations of it. Really good annotations. So good that you can build them out of reliable runtime checks which are type guards, without even writing a single type definition except to infer from them. Zod is filling a gap in JavaScript. TypeScript is facilitating that.
However, what is or isn't the goal of a language is subject to change. Libraries like Zod are trying to filling a gap in js, sure. But we are arguing that that gap is better filled by the typescript - if that requires expanding its goal or doing things that don't fit in current scope - so be it.
As to why it is better: Currently using zod requires that the author of the types uses zod's API. If they didn't anticipate that the types they are writing would need runtime checks, or prefer some other schema validation library than the one you prefer - as a consumer you need to now redefine all of these types using zod and keep them in sync. Compiler can help, sure but its still quite some work.
If typescript supported this natively, you could import some random library and use its types for runtime validation - that is not possible today, and it can't be possible until ts has first class runtime type validation.
People would also not have to learn the ts syntax and then also the zod api to achieve it.
Languages like dart demonstrate that this is practical.
I can define types using io-ts, infer a true typescript type from that to put in my d.ts files to get full ts type checking, and also have run time type checks, all from the same single definition.
The initial learning curve is admittedly a little steep, but once you have it down it's a breeze to use, and delightful.
Previous discussion: https://news.ycombinator.com/item?id=31663298 - it's downright mindblowing that all this seems to be the work of primarily a single developer.
For a less intrusive solution, https://github.com/jquense/yup is a great library to reach for whenever you're defining the shape of a network-transmitted object and don't want to introduce compilation stages.
What everyone who asks for runtime checking actually wants is:
- define once
- declaratively
- with clear validation and deserialization at boundaries where data types aren’t known
- without overhead for internal types which are well known
- without an additional build step like codegen
That is type guards! They just have to be granular and composable, and well tested at a granular and composable level. Libraries like zod and io-ts have shown how to do it. You can just use them, or many others like them, you don’t need anything new. Or you can pretty trivially build your own custom solution if they don’t scratch your particular itch.
You aren’t missing seamless runtime checks. You’re just not using them.
What everyone wants: Autogenerated type guards by class.
What everyone gets: “Remains out of scope.” and need to use other libraries. The compiler can do this significantly better (more ergonomic, less time, etc) than 3rd party libs. TS devs really should reconsider this feature.
I’m quite sure people are asking for that, but I’m quite sure they’d regret wanting it if they got it. To the extent it’s desirable, it’s a thing that should be in TC39, not in TypeScript. The wailing and gnashing of teeth over when or whether or how type coercions happen is already mythical for the underlying language. Adding it to a compiler that’s increasingly not used to compile but only to type check is gonna add ten years of gripes about JS tooling nonsense to every single discussion.
They have considered this feature. Type guards are the solution and they’re a really good solution given the circumstances.
Flow had [flow-runtime](https://github.com/gajus/flow-runtime), which was nice. There were/are a few half-baked solutions in TS ecosystem, but nothing as mature as flow-runtime was. However, I wish that TS supported this natively.
I think what OP might've meant by run-time checks is auto-generating code that wraps every unpredictable/non-guaranteed boundary with a run-time type check. So if you have some data with the "any" type data being turned into a type or a use of the "as" keyword (e.g. `someType as anotherType`), then add a run-time check that throws an error at that boundary (instead of the code erroring out elsewhere, or resulting in incorrect behavior). Something like
[1] https://www.typescriptlang.org/docs/handbook/advanced-types....
That’s a runtime type check! Any equivalent you might bake into TypeScript would do the exact same thing, but it would do it rigidly in ways people don’t expect (being strict about input types, ie “do what I say not what I mean) or rigidly in ways people don’t expect (“do what I mean as long as you mean exactly what we allow”). Again, fine grained type guards as implemented by zod/io-ts/etc solve this problem and either give you all you need already or demonstrate exactly how you can use existing language features to achieve exactly what OP and anyone else asking for runtime types wants.
https://www.typescriptlang.org/docs/handbook/release-notes/t...
Type assertions match what you describe better - they are ways to override the compiler's opinion about the type of a variable or expression. They are, as you say, unsafe in the sense that the compiler has no way of knowing if this assertion is true or not.
Sadly this isn't true: https://tsplay.dev/WvGArw
I understand that adding something like zod is out of scope for tsc. However, type guards alone are not the full answer either.
https://github.com/colinhacks/zod
You’re welcome.
(I use this library every day. It has changed my life.)
https://github.com/ar-nelson/spartan-schema
It does the same thing as Zod, but is much smaller/simpler and its types always have a JSON representation.
import macro { Deserialize } from 'ts-serde'; // I made up "import macro" syntax
import { parse } from 'ts-serde';
#[Deserialize] // I borrowed this syntax from Rust
interface Foo {
bar: string;
baz: number;
qux: Record<string, string | null>;
}
Now I can do something like:
const response = await fetch('https://www.example.com');
const foo = parse<Foo>(await response.json());
This is a much nicer typescript world to live in. I can use vanilla interfaces (or even types). I do not have to learn a separate syntax for runtime checks (such as zod, io-ts, runtypes, etc). If I want to switch from one macro implementation, my scope of changes is the annotation on the interface and the "parse" function. That is much, much easier than switching from io-ts to zod.Turns out Hegel makes it a goal to be provably correct in respect to types, whereas TypeScript makes tradeoffs for productivity.
const numbers: Array<number> = [];
// HegelError: Type "Array<number>" is incompatible with type "Array<number | string>"
const numbersOrStrings: Array<string | number> = numbers;
The very first example is already an odd choice, given that <number> is type-compatible with <string|number>. Errors should be flagged once we try to call functions on the elements, with calls on number[...] validated based on <number>, and calls on numbersOrStrings[...] validated based on the shared API between <string> and <number> (which is a tiny API surface, but there is one). numbersOrStrings[1] = "Hello, TypeError!";
Why is this not an error based on the incorrect assignment we already had? "HegelError: cannot assign to alias numberOfStrings of numbers, numbers is of type <number>". // HegelError: Property "toFixed" does not exist in "Number | undefined"
numbers[1].toFixed(1);
Heck even this error is weird: we declared numbers as an array of <number>, not an array of <number|undefined>. If you're going to be that strict, track list sizes and go "HegelError: Property numbers[1] is undefined, which is type-incompatible with <number>".As for the first one - as i understand, this will spoil the original array, as it is assigned by reference; so by writing the string into `numbersOrStrings`, you also write it into `numbers`, thus violating the type.
Unfortunately the was in Ukraine stopped the developer to work on the project
But then I realised it seems to follow Facebook's flow syntax a little more closely. I wonder if there's any shared heritage.
Not sure if this provides me enough benefit to move from TS. On the contrary it removes “non JS” features like enums. I can’t live without enums..
Besides, sometimes number values make more sense then string values. Especially when you want to do bitwise operations, which are not possible on string unions.
Still, I'd prefer:
const MyNumericEnum = {
One: 0b001,
Two: 0b010,
Three: 0b100
} as const
type MyNumericEnum = typeof MyNumericEnum[keyof typeof MyNumericEnum]
Just for explicitness. But that's my subjective opinionMore than that, refactoring tooling Just Works with enums. On the other hand, if you're passing strings around everywhere and attempt to refactor a type that is a union of strings, there's plenty of cases where refactoring tooling can't help you, and you get to manually clean up the errors.
This is subjective, so we can agree to disagree, but personally assuming I have typechecking/autocomplete I really prefer knowing exactly what I have (a string), and not hiding it behind an abstraction
And, if you ever just want the values enumerated for whatever reason, you can always do it yourself explicitly:
const Direction = {
North: 'North',
South: 'South',
East: 'East',
West: 'West'
} as const
type Direction = keyof typeof Direction
Compared with the above, the special enum syntax just creates unnecessary magic in my opinion; it even breaks TypeScript's rule of not having any runtime footprint> More than that, refactoring tooling Just Works with enums
I just entered the following in my editor:
type MyEnum = 'A' | 'B' | 'C'
const x: MyEnum = 'A'
1. When I typed open-quote in front of `=`, it intellisensed/autocompleted on the possible strings2. I was able to right-click 'A' in the type definition, click "Rename...", and then it updated both the type and the 'A' literal value with the new value
type MyEnum = 'a' | 'b' | 'c';
function test(val: MyEnum) {
return val;
}
// refactors
test('a');
// does not refactor
const testVal = 'a';
test(testVal);
The simple fact is that- as far as the type system is concerned, enums are more than simple strings, and they are useful for those differences.As for the readability, it gets a little more interesting when you are interacting with libraries or APIs outside of your control; you end up with hyphenated things, underscored, and other oddities that may not be a one-to-one match between a logical, easily readable key and the underlying value it represents.
Sure- the auto-refactor won't work here, only the typecheck, and that's the desired behavior in this case because the intended use of 'a' is not necessarily known at that point in the code. Which is the point you're making I guess, though I have to say, in practice this is an unusual case that I never really see; usually if I'm creating an enum-string it's directly in a function call, typed object literal, etc.
> as far as the type system is concerned, enums are more than simple strings
enum MyEnum {
Foo,
Bar
}
function test(val: MyEnum) {
return val;
}
test(MyEnum.Foo)
test('Foo')
I was surprised to find you were right about this; I expected the above to pass checks, but it doesn't. TypeScript actually does treat the enum as more than just its underlying value. So that's interesting- and that's the first reason I've really seen to use theseBut I think I still prefer plain values, for myself. There's just something that feels right and pure about using plain JSON-like JavaScript values, and then overlaying types on top of them that have no effect on their behavior and don't obscure their underlying representation. You know exactly what you've got, it's maximally duck-typeable, it's unchanged when it gets (de)serialized, it all just feels better to me
They happen once in a blue moon and when they do, you hate your life for a day. Maybe a week.
Plain values also have the problem of losing context. This primarily hits those who don't know the code. That's the whole selling point of OOB and related abstractions, anyway.
Both of the above happening in tandem isn't that rare and a realistic risk factor for big projects.
I'm not sure why I would? TypeScript would still lead me straight to the problem via type incompatibilities; it just wouldn't perform the fix for me automatically in this one specific case
Do you mean it doesn't compile, because it doesn't. You need to write either
const testVal: MyEnum = "a"
or const testVal = "a" as MyEnum
The latter passes typechecking with invalid values, both change automatically for me if I refractor the symbol values in VS Code. const testVal = "a"
Here, TypeScript infers that it is of type "string", and knows the value, so it is perfectly happy accepting it to ALSO be of type MyEnum. This is the problem- refactoring MyEnum means that testVal is not automatically updated, because the type is really "string" that just-so-happened to duck-type into MyEnum at the function call site.On the other hand, if MyEnum were actually an enum rather than a union of strings, this problem wouldn't have happened.
Less typing and easier to read than your solution
Using enums is not really that different from using type aliases. It’s a nicer way to write string/number values.
For web dev, having a single language everywhere is really nice.
[1]: https://github.com/JSMonk/hegel/issues/355#issuecomment-1075...
One of the reasons these libraries don't work so well with TS is that it doesn't have ML-style whole program inference and hence doesn't work so well with patterns like currying. Hegel seems more capable in that regard.
Having a great type checker is great, but if I don’t get any of the IDE functionality etc, it’s losing half the value.
(Post-)Kantian epistemology/metaphysics is about trying to see what we can know of the structure and behavior of the world prior to (without) making empirical reference to it. Intuitively, the distinction between static analysis and dynamic analysis seems to parallel the relationship between rationalist/idealist/a priori epistemology and empiricist/materialist epistemology.
Hegelian philosophy has also been taken up by some mathematicians and 'formalized' in terms of type theory and category theory. Lawvere did some work on 'categorifications' of Marx and Hegel, and I guess some HoTT people have also recently taken an interest in Hegel. You can find a little bit of background and references to other sources here: https://ncatlab.org/nlab/show/Hegel%27s+%22Logic%22+as+Modal...
Eleventy is a great name. When I search it I get nearly 100% Eleventy related results. Hugo is a not great name; if I search it and I get Hugo the movie, Hugo boss the fashion line, Hugo insurance company, etc. Next.js is an okay name, at least they explicitly include .js in the name and branding and all over their website to make it a bit more notable and manageable.
Hegel is not a great name IMO. The search space is dominated by the well renowned philosopher, and if the library does get very popular, it begins to take away from what people are usually searching for with "Hegel". Hegel.js would be a slight upgrade, but it is only referred to as "Hegel" in the main repo and website.
What a shame