0

I wanted to define some arrays with conditional elements but wasn't satisfied with approaches outlined here, so I created a helper function to make the declaration cleaner. The helper function is simple enough in vanilla JavaScript, but I've been having trouble typing it due to issues with generics.

JavaScript version

const nin = Symbol('nin')

const includeIf = (condition, item) =>
    (typeof condition === "function" ? condition(item) : condition) ? item : nin

const conditionalArray = (init) =>
    init(includeIf).filter(item => item !== nin)

/* USAGE */

const cond = false

// should equal ['foo', 'bar', 'qux'] and have type string[]
const arr1 = conditionalArray(addIf => [
    'foo',
    'bar',
    addIf(cond, 'baz'),
    addIf(word => word.length < 10, 'qux')
])

// should equal [{ name: 'Alice', age: 23 }] and have type { name: string, age: number }[]
const arr2 = conditionalArray(addIf => [
    { name: 'Alice', age: 23 },
    addIf(false, { name: 'Bob', age: 34 }),
    addIf(person => person.age > 18, { name: 'Charlie', age: 5 })
])

Updated TypeScript Version with help from jcalz

type Narrowable = string | number | boolean | undefined | null | void | {};

const nin = Symbol('nin')

type AddIf = <T, U>(condition: ((x: T) => boolean) | boolean, itemIfTrue: T, itemIfFalse?: U | typeof nin) => T | U | typeof nin
const addIf: AddIf = (condition, itemIfTrue, itemIfFalse = nin) => {
    return (typeof condition === "function" ? condition(itemIfTrue) : condition) ? itemIfTrue : itemIfFalse
}

const conditionalArray = <T extends Narrowable>(init: (addIf: AddIf) => Array<T | typeof nin>) =>
    init(addIf).filter((item): item is T => item !== nin)
remitnotpaucity
  • 170
  • 1
  • 7

2 Answers2

1

This is the best I can do for now:

const nin = Symbol('nin')

// T is the array element type    
const includeIf = <T>(condition: boolean | ((x: T) => boolean), item: T) =>
    (typeof condition === "function" ? condition(item) : condition) ? item : nin

// describe the shape of the callback
type Init<T> = (cb:
    (condition: boolean | ((x: T) => boolean), item: T) => T | typeof nin
) => (T | typeof nin)[]

// T is the element type of the array.  Accept an Init<T>, produce a T[]
export const conditionalArray = <T>(init: Init<T>) =>
    init(includeIf).filter((item: T | typeof nin): item is T => item !== nin)


const cond = true    
declare function generateWord(): string

// need to manually specify <string> below :
const arr = conditionalArray<string>(addIf => [
    "foo",
    "bar",
    addIf(cond, "baz"),
    addIf(word => word.length < 10, generateWord())
]);

The typings are essentially correct, but I can't seem to get the compiler to infer T from a value of type Init<T>. I'm guessing the nested/circular types are just too much for it. So instead of just calling conditionalArray(addIf => ...), I have to call conditionalArray<string>(addIf => ...), or else T gets the "default" value of {} and you get both errors and a too-wide array type Array<{}> as output.

Hope that's of some help, anyway.


Update: good call making the type of init only generic in the type of its return value; that seems to un-confuse the compiler enough for inference to work.

So here's the best we have for now:

const nin = Symbol('nin')

type IncludeIf = typeof includeIf
const includeIf = <T>(condition: ((x: T) => boolean) | boolean, item: T): T | typeof nin => {
    return (typeof condition === "function" ? condition(item) : condition) ? item : nin
}

const conditionalArray = <T>(init: (includeIf: IncludeIf) => Array<T | typeof nin>) =>
    init(includeIf).filter((item): item is T => item !== nin)

To address your issues:

const arr1 = conditionalArray(addIf => [
    addIf(true, 1), addIf(true, 'a')
]); // Array<1 | "a"> 

Are you sure this is too restrictive? There's a lot of machinery in TypeScript around trying to determine when literal types should be left narrow or widened. I think Array<1 | "a"> is a perfectly reasonable type to infer for the value [1, "a"]. If you want to widen it, you can tell the compiler that 1 and 'a' are not meant to be literal:

const arr1 = conditionalArray(addIf => [
  addIf(true, 1 as number), addIf(true, 'a' as string)
])

If you really want to force the return type of conditionalArray() to always be widened, then you can use a conditional type like this:

type WidenLiterals<T> =
    T extends string ? string :
    T extends number ? number :
    T extends boolean ? boolean :
    T

const conditionalArray = <T>(
  init: (includeIf: IncludeIf) => Array<T | typeof nin>) =>
  init(includeIf).filter((item): item is T => item !== nin) as
  Array<WidenLiterals<T>>;

const arr1 = conditionalArray(addIf => [
  addIf(true, 1), addIf(true, 'a')
]) // Array<string | number>

That works, but it might be more complicated than it's worth.


Your next issue:

const arr2 = conditionalArray((addIf) => [
  1, 2, 3, addIf(true, 4), addIf(false, '5'), addIf(false, { foo: true })
]); // Array<number | "5" | {foo: boolean}>

How important is it to you that the compiler recognize when you pass a literal false as the condition in addIf()? I would expect that in real world code you would never do this... if you know at compile-time that the condition is false, then you would just leave it out of the array. Conversely, if at compile-time you're not sure whether the condition is true or false, then you should want a type like the above even if the value happens to contain only numbers.

However, again, you can force the compiler to go through such logic via conditional types:

const includeIf = <T, B extends boolean>(condition: ((x: T) => B) | B, item: T) => {
  return ((typeof condition === "function" ? condition(item) : condition) ? item : nin) as
    (true extends B ? T : never) | typeof nin;
}

const arr2 = conditionalArray((addIf) => [
  1, 2, 3, addIf(true, 4), addIf(false, '5'), addIf(false, { foo: true })
]) // Array<number>

That works, but again, it might be more complicated than it's worth.


UPDATE 2:

Assuming you want to forget about literal false and you'd like to keep the element type as narrow as possible in all cases, you can do something like this:

type Narrowable = string | number | boolean | undefined | null | void | {};

const conditionalArray = <T extends Narrowable>(
    init: (includeIf: IncludeIf) => Array<T | typeof nin>
) => init(includeIf).filter((item): item is T => item !== nin)

const arr1 = conditionalArray(addIf => [1, "a"]);
// const arr1: (1 | "a")[]

That's because having string, number, and boolean mentioned explicitly inside the generic constraint for T gives the compiler a hint that a literal type is desired.

See Microsoft/TypeScript#10676 for more details about how and when the compiler chooses to widen or preserve literal types.


Okay, hope that helps. Good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for the response. I just added my current best attempt above. What you have is pretty close to what I've been playing around with. I was also getting `{}[]` as a type, then I switched it so now it's too specific. I think I may need some `infer` magic but I'm pretty unfamiliar with it – remitnotpaucity May 15 '19 at 02:44
  • Updated to address the issues you have with your current best attempt (although honestly I would just leave it the way you have it). – jcalz May 15 '19 at 13:28
  • I think you're completely right about passing a _literal_ `false`, there's no good use case for that. However, it looks like adding the conditional type to `includeIf` also widens the return type. My only concern with having literal types instead of wider types was that `conditionalArray((addIf) => [1, 'a'])` is typed as `(string | number)[]` whereas `conditionalArray((addIf) => [1, addIf(true, 'a')])` is typed as `(number | "a")[]` and I think those types should be consistent. Do you know why adding the conditional type also widens the return type? – remitnotpaucity May 15 '19 at 16:15
  • Also instead of widening the types to make them consistent, do you know if it's possible to get `conditionalArray((addIf) => [1, 'a'])` to be typed as `(1 | "a")[]` without making it a readonly? – remitnotpaucity May 15 '19 at 16:16
  • 1
    Updated again. I linked the [GitHub pull request](https://github.com/Microsoft/TypeScript/pull/10676) in my answer so you can read about how the compiler decides when to keep literal types literal and when it widens them. Frankly I don't know why the conditional `includeIf` changes things; probably you'd have to read the compiler code and/or step through it with a debugger to understand, or ask someone who's worked on the compiler (i.e., not me). I feel lucky just to have some tricks to widen/narrow literals when it does the opposite of what I want. – jcalz May 15 '19 at 16:31
0

Possible solution in TypeScript (modules export/import removed)

const nin = Symbol('nin')

type includeIfFunc = (condition: boolean | ((item: string) => boolean), item: string) => Symbol | string;
type addIfFunc = (addIfFunc: includeIfFunc) => (Symbol | string)[];
type conditionalArrayFunc = (init: addIfFunc) => (Symbol | string)[];

const includeIf: includeIfFunc = (condition: boolean | ((item: string) => boolean), item: string): Symbol | string =>
    (typeof condition === "function" ? condition(item) : condition) ? item : nin

const conditionalArray: conditionalArrayFunc = (init: addIfFunc) =>
    init(includeIf).filter(item => item !== nin)

const cond = true

const arr = conditionalArray(addIf => [
    "foo",
    "bar",
    addIf(cond, "baz"),
    addIf(word => word.length < 10, "generateWord")
])
Fyodor Yemelyanenko
  • 11,264
  • 1
  • 30
  • 38
  • Thanks for the response. Sorry if my question wasn't clear but I'm trying to have the `conditionalArray` function work with arrays of any type, not just strings – remitnotpaucity May 15 '19 at 01:56
  • Do you want same type array of array containing mixed type variables? – Fyodor Yemelyanenko May 15 '19 at 02:13
  • I would like arrays of a single type to be properly typed and arrays containing multiple types to have a union type. ie `conditionalArray(addIf => [addIf(true, 1), addIf(true, 'a')])` should have type `(number | string)[]` – remitnotpaucity May 15 '19 at 02:28