Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add std.cast #2184

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open

Add std.cast #2184

wants to merge 1 commit into from

Conversation

jneem
Copy link
Member

@jneem jneem commented Mar 4, 2025

This adds a std.cast function that's like the payload-carrying version of std.typeof. The motivation is to make it easier to write statically typed code that checks dynamic types.

The immediate motivation was for the contracts in json-schema-to-nickel, where statically typing the max_properties contract looks like

{
max_properties: Number -> Dyn = fun max => std.constract.custom (fun label value =>
  if std.is_record value then
    if std.record.length (value | {_: Dyn}) <= max then ... else ...
#                                 ^^^ annoying contract annotation, because value lacks a static type
  else
    'Error ...
)
}

With this addition, you can write it as

{
max_properties: Number -> Dyn = fun max => std.constract.custom (fun label value => std.cast value
  |> match {
    'Record r => if std.record.length r <= max then ... else ...
    _ => 'Error ...
  })
}

@jneem jneem requested a review from yannham March 4, 2025 05:28
Copy link
Contributor

github-actions bot commented Mar 4, 2025

Copy link
Member

@yannham yannham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the motivation for such an operation. As preliminary questions:

  • What is the motivation to make this a primop? Is it to avoid undue contract evaluation? Because otherwise I guess you could implement it in user-code as x |> %typeof% |> match { 'Number => 'Number (x | Number), ...etc}.
  • I think it's a nice solution with the current features of Nickel. In the long term, though, I wonder if we won't want to implement some form of occurrence typing (or flow-sensitive typing), which would make the original example work without contract annotation (à la TypeScript). In this case I'm wondering if adding a different way of doing that now is a good idea on the long-term. On the other hand, it's filling a need, and it's not awful if at some point we have cast and typeof overlap; we can deprecate cast, which is still quite readable/understandable.

So I guess there are three possible solutions:

  • This PR
  • This PR but without the primop
  • Move cast to an external lib for the time being (or let people implement it if they need it)

@jneem
Copy link
Member Author

jneem commented Mar 4, 2025

I guess there isn't good evidence that this is needed in the standard library. The main reason I went for the primop was to avoid the contract application for possible (but unmeasured) performance reasons.

Regarding occurrence/flow-sensitive typing, I'm probably biased by using rust as my main language for many years now, but what benefit does it have over ADTs + pattern matching?

@yannham
Copy link
Member

yannham commented Mar 4, 2025

Regarding occurrence/flow-sensitive typing, I'm probably biased by using rust as my main language for many years now, but what benefit does it have over ADTs + pattern matching?

flow-sensitive typing makes sense for gradually typed language, precisely for this use-case: you get some untyped data in a typed context, but thanks to some blessed test, the typechecker can refine the type of the values in each branch. TypeScript uses this to emulate ADTs, but I think it's useful in general. Since this kind of decision tree if typeof foo == bar is common in such languages, flow-sensitive typing makes it possible to write statically typed code while keeping such dynamic tests in an idiomatic/natural way.

This technique doesn't really make sense as it is for strongly, statically, nominally-typed language like Rust, Haskell, OCaml, where you can't really "cast" or refine values to begin with. Although you could argue that refinement types, dependent types and GADTs do require vaguely similar mechanisms.

However, I also realize that all the simple examples I can think of could probably be covered by the "produce an ADT" approach as well. So maybe cast is covering 90% of the examples and we shouldn't bother with occurrence typing. Let's sleep on it and discuss that on the next weekly?

@jneem
Copy link
Member Author

jneem commented Mar 5, 2025

Sure, I can also just open an issue and we can let it sit for a while. I don't think there's any urgency for this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants