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!