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)
0

2 Answers 2

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!

Sign up to request clarification or add additional context in comments.

5 Comments

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
Updated to address the issues you have with your current best attempt (although honestly I would just leave it the way you have it).
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?
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?
Updated again. I linked the GitHub pull request 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.
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")
])

3 Comments

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
Do you want same type array of array containing mixed type variables?
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)[]

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.