poteat / hkt-toolbelt

✨Functional and composable type utilities

Home Page:http://hkt.code.lol

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Investigate feasability of replacing Type._$cast<T, U> with T & U

poteat opened this issue · comments

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 the Fold abstraction? Would the Reduce 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 the x in the f(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-applied Fold is passed into other Kinds 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 as Kinds.
  • 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 from Kind.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. Using Extract 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 to cast (assuming this is the name you choose)
  • create a type alias called _$cast that points to cast
  • update your docs to use Type.cast instead of Type._$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):

  1. in the signature, as constraints (which are then understood by the compiler, and enforced in the body of the definition)
  2. 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:

  1. Defining kinds with enforced constraints for its inputs and outputs
  2. 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.