The extends clause in TS is a powerful aid to have your types being manipulated the same way your runtime data structures are modified. In fact, sometimes its a more powerfully compact way of doing this ... which then leads me to the hope that there might be some way to inform the runtime system of types.
Here's a simple example of what I'd like to be able to do:
const toFooOrNotToFoo = ifTypeOf(myVariable).extends<string>();
In the above example, it's easy enough to infer the type of myVariable and then in the type system determine whether the extends relationship passes but how can we bring that back to the runtime system? I do secretly suspect that this may be a bridge too far but it would be handy so I'm asking the community.
My first attempt at a solution was a bold attempt to combine casting, inference and the keyof operator to work the magic. Ended in tears and my license to practice magic was revoked. After years of therapy and anger management I eventually took a more practical turn and decided I would need a representative runtime variable of each type so I could effectuate the runtime comparison next to the type comparison.
function runtimeExtendsCheck<
TValue extends any,
TBase extends any
>(val: TValue, base: TBase, narrow: boolean = false): TValue extends TBase ? true : false {
if (typeof val !== typeof base) {
return false as TValue extends TBase ? true : false;
}
switch (typeof val) {
case "boolean":
case "string":
case "number":
case "symbol":
case "bigint":
return narrow
? (val === base) as TValue extends TBase ? true : false
: true as TValue extends TBase ? true : false;
case "undefined":
return true as TValue extends TBase ? true : false;
case "function":
? (val.toString() === (base as Function).toString()) as TValue extends TBase ? true : false
: true as TValue extends TBase ? true : false;
case "object":
if (val === null && base === null) {
return true as TValue extends TBase ? true : false;
}
return keys(base as object).every(i => runtimeExtendsCheck(val[i], base[i], narrow)) as TValue extends TBase ? true : false;
}
}
And with this runtime checker I then added the following runtime API:
const ifTypeOf = <
N extends Narrowable,
TValue extends Record<keyof TValue, N> | number | string | boolean | symbol
>(val: TValue) => ({
extends: <TBase extends any>(base: TBase) => {
const valid = runtimeExtendsCheck(val, base, false);
return (valid ? true : false) as TValue extends TBase ? true : false;
},
narrowlyExtends: <NB extends Narrowable, TBase extends Record<keyof TBase, NB> | number | string | boolean | symbol>(base: TBase) => {
const valid = runtimeExtendsCheck(val, base, true);
return (valid ? true : false) as TValue extends TBase ? true : false;
});
This API then let's me do something like the following:
const done = ifTypeOf({
wakeUp: true
}).extends({
wakeUp: true,
eatBreakfast: true,
drinkCoffee: true
});
Now it may not be clear yet but the comparison being done is a against an object with props who's value is boolean not true. This is expected behavior for the API we created but we probably wanted the literal value true. For instance imagine this:
const done = ifTypeOf({
wakeUp: false,
eatBreakfast: false,
drinkCoffee: false
}).extends({
wakeUp: true,
eatBreakfast: true,
drinkCoffee: true
});
The variable done is still true when casual observation would indicate it should be false. We can make our lives even worse if we take what seems like the sensible step of adding as const like so:
const done = ifTypeOf({
wakeUp: false,
eatBreakfast: false,
drinkCoffee: false
}).extends({
wakeUp: true,
eatBreakfast: true,
drinkCoffee: true
} as const);
Now the type system is entirely certain that done is in fact false but the runtime is of the complete opposite view that it is true.
To avoid this we've added the narrowlyExtends part of the API and this allows to make this work with:
const done = ifTypeOf({
wakeUp: false,
eatBreakfast: false,
drinkCoffee: false
}).narrowlyExtends({
wakeUp: true,
eatBreakfast: true,
drinkCoffee: true
} as const);
This works for this use case and in the case you're now feeling good let me tell you that this is not nearly good enough. What about functions? There is really no way outside of possibly some sort of AST analysis (this is almost surely a bad idea) that you could really approximate what the structure of a function is (aka, param types and return type).
So this idea of doing this "in parallel" clearly has its limits. It may well suit some use cases but it's a bit dangerous and simply can't do some things we might want. The point is ... is this "the way" (as the Mandalorian and Confucius might say)?
This brings me full circle back to my idealistic dream ... is there a way to have the type system inform the run-time values so that the types can dictate a singular logic rather than rely on this Frankenstein monster? If this isn't possible, does anyone know if anyone has developed a better Frankenstein?
Note: apologies if this was a bit long winded but I've been trying to solve this problem as write it up and felt the extra detail might be worth expressing for some to understand the flaws of this dual approach