ts-pattern icon indicating copy to clipboard operation
ts-pattern copied to clipboard

Pattern matching on array length

Open tam-carre opened this issue 2 years ago • 5 comments

Is your feature request related to a problem? Please describe. It is difficult to tersely pattern match array elements and tail in TypeScript. See this elm syntax:

listInfo : List a -> String
listInfo list =
    case list of
        [] -> "This is an empty list"
        [ a ] -> "This list has exactly one element a"
        [ a, b ] -> "This list has exactly two elements a, b"
        a::b::[] -> "This list also has exactly two elements a, b"
        [ a, b, c ] -> "This list has exactly three elements a, b, c"
        a::b::c::rest -> "This list has more than 3 elements a, b, c; `rest` is a `List a`"
        x::xs -> "x is the first element of this list (guaranteed to exist), xs is the rest. You can guarantee xs to be non-empty if you previously match for `[ a ]` or ` a::[]`"

JavaScript/TypeScript offers out of the box a form of destructuring that is very limited:

const destructureArray = ([x, ...xs]) => `The array's head is ${x}, the array's tail is ${xs}`

There are many type safety issues with the above approach:

  • x is undefined if the input is an empty array, or if the first array element is explicitly undefined
  • it doesn't allow for branching depending on how many array elements exist

Describe the solution you'd like A comprehensive way to pattern match on array length and individual elements at the start of the array. Here's an example API that most closely resembles Elm/Haskell pattern matching.

const nums: number[] = [1, 2, 3, 4, 5, 6]

match(nums)
  .with(P.list(), (emptyArr) => "type of emptyArr is `[]` ")
  .with(P.list(P.el), (arr) => "type of arr is `[number]`")
  .with(P.list(P.el, P.el), (arr) => "type of arr is [number, number]")
  .with(P.list(P.el, P.rest), (arr) => "type of arr is `[number, ...number[]]`")
  .with(P.list(P.el, P.el, P.rest), (arr) => "type of arr is `[number, number, ...number[]]`")
  .exhaustive()

match(nums)
  .with(P.list(P.el), ([a]) => "type of a is `number` as opposed to `number | undefined`")
  .with(P.list(P.el, P.el), ([a, b]) => "a and b are both of type number as opposed to `number | undefined`")
  .with(P.list(P.el, P.rest), ([x, ...xs]) => "type of x is `number`, type of xs is `number[]`")
  .with(P.list(P.el, P.el, P.rest), ([a, b, ...rest]) => "a and b are `number`, rest is number[]")
  .exhaustive()

This is an example API but ergonomics should be studied further.

Careful consideration must be given to what is considered an exhaustive pattern matching of a list and how pattern order matters (or doesn't). This should be researched and confirmed, but for quick reference, an exhaustiveness check would look like:

  • P.list() case must be present
  • P.list(P.rest) must be present, unless P.list(P.el) is present, in which case P.list(P.rest) may be substituted with P.list(P.el, P.rest), etc

Describe alternatives you've considered

I've attempted using type guards and .when but there are a number of issues:

const nums: number[] = [1, 2, 3, 4, 5, 6]

const isEmpty = <T,>(arr: T[]): arr is [] =>
  arr.length === 0

const hasOneEl = <T,>(arr: T[]): arr is [T] =>
  arr.length === 1

const hasTwoEls = <T,>(arr: T[]): arr is [T, T] =>
  arr.length === 2

const hasAtLeastOneEl = <T,>(arr: T[]): arr is [T, ...T[]] =>
  arr.length >= 1

match(nums)
  .when(isEmpty, (emptyArr) => "OK: type of emptyArr is `[]` ")
  .when(hasOneEl, (arr) => "ISSUE: type of arr is `[unknown]`")
  .when(hasOneEl, (arr: [number]) => "MEH: type of arr is [number], but manual annotation necessary")
  .when(hasOneEl, (arr: [string]) => "ISSUE: coercion of of arr to [string] is possible")
  .when((nums) => hasOneEl(nums), (arr) => "ISSUE: type of arr is `number[]`")
  .when((nums) => hasOneEl(nums), (arr: [number]) => "MEH: type of arr is `[number]` with anontation")
  .when((nums) => hasOneEl(nums), (arr: [string]) => "OK: this is not possible")
  .when(hasTwoEls, (_arr) => "ISSUE: type of arr is [unknown, unknown]")
  .when(hasAtLeastOneEl, (arr) => "ISSUE: type of arr is `[unknown, ...unknown[]]`")
  .when((nums) => hasAtLeastOneEl(nums), (arr) => "ISSUE: type of arr is number[]")
  .exhaustive(/* ISSUE: does not detect that this pattern matching is exhaustive */)

As an alternative to my P.list example API, something similar to my attempt could look like:

match(nums)
  .when(P.isEmpty, (emptyArr) => "type of emptyArr is `[]` ")
  .when(P.hasOneEl, (arr) => "type of arr is `[number]`")
  .when(P.hasTwoEls, (arr) => "type of arr is [number, number]")
  .when(P.hasAtLeastOneEl, (arr) => "type of arr is `[number, ...number[]]`")
  .when(P.hasAtLeastTwoEls, (arr) => "type of arr is `[number, number, ...number[]]`")
  .exhaustive()

Assuming the type issues and pointfree fiddliness can be solved, such a solution, despite being different from Elm/Haskell-type matching, might be easier to understand for Typescript as well as easier to implement.

Additional context /

tam-carre avatar Aug 17 '22 08:08 tam-carre

Hi

That's a good feature request, I was thinking about adding a P.rest(subpattern?) pattern that could be combine with tuple in patterns that would look like [1, 2, P.rest(P.number)] (which would match the type [1, 2, ...number[]]) to cover this use case. Would that work for you?

gvergnaud avatar Aug 26 '22 15:08 gvergnaud

Duh, that seems like the simplest change, with all the benefits. Sounds good.

Since I like listing out the different usages:

const nums: number[] = [1, 2, 3, 4, 5, 6]

match(nums)
  // Already implemented
  .with([], (emptyArr) => "type of emptyArr is `[]`")
  .with([P.number], (arr) => "type of arr is `[number]`")
  .with([P.number, P.number], (arr) => "type of arr is [number, number]")

  // New syntax
  .with([P.number, P.rest(P.number)], (arr) => "type of arr is `[number, ...number[]]`")
  .with([1, P.number, P.rest(P.number)]), (arr) => "type of arr is `[1, number, ...number[]]`")
  .exhaustive()

// Combined with JS destructuring
match(nums)
  // Already implemented
  .with([P.number], ([a]) => "type of a is `number` as opposed to `number | undefined`")
  .with([P.number, P.number], ([a, b]) => "a and b are both of type number as opposed to `number | undefined`")

  // New syntax
  .with([1, P.rest(P.number)], ([x, ...xs]) => "type of x is `1`, type of xs is `number[]`")
  .with([P.number, P.number, P.rest(P.number)], ([a, b, ...rest]) => "a and b are `number`, rest is number[]")
  .exhaustive()

ghost avatar Aug 26 '22 16:08 ghost

_

ghost avatar Aug 26 '22 17:08 ghost

I gave this a little more thoughts and I'm leaning toward an API that would look like this instead:

const xs: unknown[] = [1, 2, 3, 'a', 'b', 'c'];

match(xs)
  .with([P.any, ...P.array()], (xs) => [])             // xs: [unknown, ...unknown[]]
  .with([...P.array(), 7], (xs) => [])                 // xs: [...unknown[], number]
  .with([42, ...P.array(P.number)], (xs) => [])        // xs: [42, ...number[]]
  .with([42, ...P.array(P.number), '!'], (xs) => [])   // xs: [42, ...number[], '!']
  .with([1, 2, ...P.array(P.number)], (xs) => [])      // xs: [1, 2, ...number[]]
  .with([...P.array(P.string), 'a', 'b'], (xs) => [])  // xs: [...string[], 'a', 'b']
  .otherwise(() => [])

This way we can cover all the features of Variadic Tuple types, like leading spread [...type1[], type2] , middle spread [type1, ...type2[], type3].

gvergnaud avatar Sep 14 '22 12:09 gvergnaud

_

ghost avatar Sep 14 '22 15:09 ghost

I started implementing this, here is the WIP PR 👉 https://github.com/gvergnaud/ts-pattern/pull/114

gvergnaud avatar Sep 29 '22 13:09 gvergnaud

Wow, can't wait to use this!

philihp avatar Jan 05 '23 00:01 philihp

This is going to be part of TS-Pattern v5 (draft pr: #139)

gvergnaud avatar Feb 19 '23 18:02 gvergnaud

Added in https://github.com/gvergnaud/ts-pattern/releases/tag/v5.0.0!

gvergnaud avatar Jun 15 '23 18:06 gvergnaud