hkt-toolbelt
hkt-toolbelt copied to clipboard
Investigate feasability of replacing Type._$cast<T, U> with T & U
This would be a big win in efficiency, readability, and so if this equivalence is true.
I investigated this a little bit and the equivalence seems definitely false - still may be opportunities to improve performance though, so I'll leave this open.
Couldn't you just do Extract<T, U>
?
@ahrjarrett
Counterexample:
T1 = Type._$cast<string, `0x${string}`> // `0x${string}`
T2 = Extract<string, `0x${string}`> // never
Extract
wouldn't work as a direct substitue since its application is specific to unions, but maybe you had a different usage in mind?
@MajorLift thanks for your reply.
I was picturing the type arguments being swapped in T2
. I was wondering if that would make things more readable, since users are probably more familiar with Extract
than _$cast
.
Another option would be to embed the constraint into initial encoding directly, like this. Doing it that way lets you access the property with constraints already applied (so there's no need to cast at all).
@ahrjarrett Regarding your playground link:
- It isn't compatible with the curried, point-free style we're going for. I'd encourage you to try replicating these partially-applied higher-order function calls using your HKT encoding, and explore the limitations you might come across. For example, could your
Concat
implementation be used to create theFold
abstraction? Would theReduce
HKT used to achieve this be generally applicable? - Point-free style means all of our interfaces are unary. That is, they accept a single "free" or independent variable, which can be applied using
$
. This free variable is thex
in thef(x)
, and we're specifically avoiding encoding it as a generic parameter in order to ensure that our HKTs are "first-class" e.g. in the playground link above, the partially-appliedFold
is passed into otherKind
s without needing to be invoked first using generic arguments. To achieve this, the source of truth for free variables must be the_
property, not a generic parameter. This is the whole point of encoding HKTs asKind
s. -
Concat
only constrains the input type(s). We need our HKTs to be constrained and validated in both their input and output types. We use these constraints to check whether a series of composed HKTs is actually composable.
Regarding _$cast
vs. Extract
,
_$cast
needs to downcast any supertype-subtype combination, but Extract
only applies to unions. Extract
simply doesn't have the relevant functionality that's needed here.
type T1 = Type._$cast<boolean, true> // true
type T2 = Type._$cast<true, boolean> // true
type T3 = Type._$cast<string, `0x${string}`> // `0x${string}`
type T4 = Type._$cast<`0x${string}`, string> // `0x${string}`
type T5 = Type._$cast<number, 0> // 0
type T6 = Type._$cast<0, number> // 0
type T7 = Extract<boolean, true> // true
type T8 = Extract<true, boolean> // true
type T9 = Extract<string, `0x${string}`> // never
type T10 = Extract<`0x${string}`, string> // `0x${string}`
type T11 = Extract<number, 0> // never
type T12 = Extract<0, number> // 0
@ahrjarrett I applied your suggestion to a new HKT contribution from our latest PR: playground link. Hopefully this example makes my explanation easier to understand.
- Specifically, the errors should show why
Type._$cast
is needed for free variables. - The awkward formulation in the
Kind.Kind
generic arguments should explain the design choice to have all HKTs uniformly extend fromKind.Kind<(...args: never[]) => unknown>
(which is sufficient to make them valid first arguments of$
).
Hey @MajorLift, thank you again for the detailed response.
To clarify, my comment about Extract
wasn't meant as a challenge as much as a suggestion. I was thinking it could help make the library more accessible / legible, since Type._$cast<left, right>
might be a lot for new users to absorb.
I'm not as familiar with hkt-toolbelt's internals, and so I can't speak to your use case exactly, but it's worth clarifying that Extract
is far more useful than you're giving it credit for.
To illustrate what I mean, consider a version of Extract
that is not distributive:
type Extract<left, right> = [left] extends [right] ? left : never
If Extract only applied to unions, then this type would have no effect. But here you can see** that it does in fact fix the type errors in the example you shared.
Granted, Extract
isn't a drop-in replacement for _$cast
(the change isn't one that can be mechanically applied, since they do different things) -- so it might not be what you're looking for. But I thought it was worth mentioning, since Extract
is (IMO) far more useful than people often give it credit for.
- edit: looks like I accidentally shared your playground -- fixed
Also, I'd love to take you up on the challenge to implement fold
. I don't think I have time to try today, but maybe this weekend I'll give it a shot :)
-
Extract
is a built-in utility type in TypeScript with a specific use case pertaining to unions. We'll have to come up with a different type name for this discussion. It took me a while to understand that you're not talking about the built-in type. UsingExtract
as a user-facing type name would be quite a bit confusing. - From your implementation, making the conditional non-distributive might be an improvement. This has gotten me thinking about some possible updates we could make to
Type._$cast
so thanks for that. - But it sounds like the suggestion you're making is about naming conventions, not implementation.
_$
is our convention for internal/private generic types that our HKTs rely on under the hood, but are not HKTs themselves. Their names are not intended to be readable, but to be easily distinguishable from the PascalCase names of our user-facing HKT interfaces. - That said, maybe we could drop the
_$
prefix and just use camelCase e.g.Type.cast
, especially since we're exporting these at the package-level anyway? Might be worth considering to lower the bar of entry, although it would be a huge breaking change. @poteat Does this sound like something you might be open to?
I think my attempt to be explicit actually introduced ambiguity here 😅 so just to make sure we're on the same page: the reason I made Extract
non-distributive was show that Extract
is not "for" unions.
You can test that, if you're skeptical, by removing the custom definition of Extract
from the example I shared, to see if it causes any type errors.
Thinking about Extract
as being a "utility for unions" is useful, but only as a heuristic. At the heart of Extract
is the matter of assignability: it can be used to filter members from a union, but that's just a special case.
As we've established, Extract
behaves differently for unions vs. non-unions, even if it's non-distributive.
Is there a specific benefit from using Extract
that makes you suggest it, other than the readability of its name?
Given that we would never want typeof x
to resolve to never
unless we're specifically casting to never
, I'm not sure I see the use case of Extract
as a replacement of Type._$cast
.
Given that we would never want typeof x to resolve to never unless we're specifically casting to never, I'm not sure I see the use case of Extract as a replacement of Type._$cast.
Ah, that must be where we're missing each other -- I didn't realize that was the behavior you wanted. No worries :) my goal in suggesting it was simply because it seemed like a readability win, which seemed to be what Mike was wanting.
As far as fold goes, is this what you had in mind? https://tsplay.dev/WkqRpN
That said, maybe we could drop the _$ prefix and just use camelCase e.g. Type.cast, especially since we're exporting these at the package-level anyway? Might be worth considering to lower the bar of entry, although it would be a huge breaking change. @poteat Does this sound like something you might be open to?
Thought about this a little more -- if y'all do decide to go this route, it might simplify things if you:
- rename
_$cast
tocast
(assuming this is the name you choose) - create a type alias called
_$cast
that points tocast
- update your docs to use
Type.cast
instead ofType._$cast
- add a JSDoc comment to handle the deprecation, e.g.:
/** @deprecated Use {@link cast `Type.cast`} instead */
FWIW, the reason I'm taking an interest is because I think it would be a big win for new users. hkt-toolbelt
is a wonderful library, and has the most robust and complete HKT API in the ecosystem.
But from an accessibility standpoint, my feedback would be that we're asking new users for too much, too soon.
| @poteat Does this sound like something you might be open to?
I'm down; any improvements to readability are well worth it.
On the other hand, I think visual distinguishment makes the more complicated subroutines more readable - as well, I aesthetically dislike only having a Levenstein distance of 'one' between modules; I feel like it introduces the chance of typos / misunderstandings.
As a middle ground, I would support Type.cast
as a one-off permanent alias, since Type._$cast
is actually necessary for establishing constraints for hkt generics as discussed.
@poteat I agree with the point about visual distinction, and don't feel good about using camelCase for type names for the same reason, especially since we support subpath imports. Type.cast
could work, maybe, but cast
becomes too easy to confuse with value-level variables.
As I understand it, @ahrjarrett's suggestion is that we replace all of the Type._$cast
instances in our HKT definitions with the Type.cast
alias for readability. This seems like a lot of trouble to make an internal implementation detail accessible, which shouldn't become an issue until the user starts wanting to define custom HKTs.
Perhaps adding a brief section to the readme explaining the difference between our curried HKTs and uncurried generics might be a more straightforward solution? This might be an update we would want to make in any case since we do deploy the uncurried generics.
@ahrjarrett That's a cool solution! Here are the equivalent implementations using hkt-Toolbelt for comparison: https://tsplay.dev/w2gdYN.
Some observations:
- The ability to partially apply arguments out of order (e.g. ex_03, ex_04) isn't readily available to a point-free style implementation by design. This flexibility is a strength of your solution.
- Your
Kind
construct has a hard-coded limit on arity. Is there a way to make this unnecessary? - How does a non-curried
Kind
interface affect its ability to form function composition pipelines? Is it possible to implement a solution that supports free composition of arbitrary functions in any order, regardless of their arity and function signature?
Hey @MajorLift, thanks for taking a look.
The ability to partially apply arguments out of order (e.g. ex_03, ex_04) isn't readily available to a point-free style implementation by design. This flexibility is a strength of your solution.
I included the partial application examples not because I think it's a strength of the encoding per se (I actually wasn't sure if it would work until I created the sandbox).
The point I was trying to make was that you get a lot of things for free when you have the ability to bind and rebind type parameters.
So in the examples I shared, I bind each type parameter twice (and only twice):
- in the signature, as constraints (which are then understood by the compiler, and enforced in the body of the definition)
- at the call site, as concrete arguments
The any-ts
encoding is interesting/unique for other reasons too, I think (I can answer your other questions in a separate comment), but I wanted to clarify that my goal here was to demonstrate the viability of an approach that more closely mirrors how function application works at runtime -- that is, by declaring constraints at the beginning, as a separate step, rather than after the fact (behavior that _$cast
helps us recover).
Like you alluded to before, both are valid solutions -- they just come with different tradeoffs.
@ahrjarrett Both binding and rebinding sound like basic capabilities of hkt-Toolbelt, if I'm understanding you correctly:
- Defining kinds with enforced constraints for its inputs and outputs
- Uncurried argument binding at the call site
I would encourage you to look into implementing arbitrary function composition. It would be interesting to see how you decide to handle the complexity that arises from handling arbitrary arity, which is avoidable when working with curried, unary functions.