sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.56k stars 148 forks source link

Cannot use .map() inside Union with Literal #885

Closed EvHaus closed 1 month ago

EvHaus commented 1 month ago

I'm trying to figure out why I don't get literal types when using a .map() inside T.Union(). Consider this code:

const DATA = [{value: 'Bob'}, {value: 'Stella'}] as const;

const thing = t.Union([
    t.Literal(DATA[0].value),
    t.Literal(DATA[1].value),
])

// ✅ This gives me the correct type:
// => "Bob" | "Stella"

However, this doesn't work:

const DATA = [{value: 'Bob'}, {value: 'Stella'}] as const;

const thing = t.Union(
    DATA.map(({ value }) => t.Literal(value))
)

// ❌ This gives me the wrong type:
// => never

Is this a bug, or a limitation of typebox?

sinclairzx81 commented 1 month ago

@EvHaus Hi,

Is this a bug, or a limitation of typebox?

It's neither. The issue is that DATA.map returns an array but where TypeBox expects a fixed size tuple for Union (and other types). Your first example works because you have explicitly passed an array with a known size (which TypeScript can observe as a tuple), the second example does not as the .map() function returns an array where interior elements are taken as a union. Consider.

const A = [1, 2, 3, 4] as const   // type A = readonly [1, 2, 3, 4]

const B = A.map(value => value)   // type B = (1 | 2 | 3 | 4)[]  - (no longer a tuple)

Here is the equivalent when mapping for TLiteral types.

TypeScript Link Here

const DATA = [{value: 'Bob'}, {value: 'Stella'}] as const;

const VARIANTS = DATA.map(({ value }) => Type.Literal(value)) // hover VARIANTS
//
// TLiteral<"Bob" | "Stella">[]  -- Incorrect
//
// [TLiteral<'Bob'>, TLiteral<'Stella'>] -- Correct

TypeBox does not support the TLiteral<"Bob" | "Stella">[] form as it's very expensive to derive the constituents of "Bob" | "Stella" within the type system. The solution instead is to write a little bit of type level programming that maps each element of DATA 1 by 1 which proves the compiler both the mapping and size of the array (making it a tuple). The following achieves this.

TypeScript Link Here

import { Type, TLiteral } from '@sinclair/typebox'

// --------------------------------------------------------------------------------
// DataToLiteralTuple
// --------------------------------------------------------------------------------

type Element = { value: string }

// This is a .map() operation in the type system
type TDataToLiteralTuple<Data extends Element[], Result extends TLiteral[] = []> = (
  Data extends [infer L extends Element, ...infer R extends Element[]]
    ? TDataToLiteralTuple<R, [...Result, TLiteral<L['value']>]>
    : Result
)
function DataToLiteralTuple<Data extends Element[]>(elements: readonly [...Data]): TDataToLiteralTuple<Data> {
  return elements.map(element => Type.Literal(element.value)) as TDataToLiteralTuple<Data> 
}

// --------------------------------------------------------------------------------
// Example
// --------------------------------------------------------------------------------

const DATA = [{value: 'Bob'}, {value: 'Stella'}] as const;

const thing = Type.Union(DataToLiteralTuple(DATA))

Above, you would use DataToLiteralTuple instead of DATA.map as this function returns the correct computed tuple. The above pattern is fairly common when mapping external data structures to TypeBox types, but does require a little bit of type level programming to implement correctly.

Hope this helps S

EvHaus commented 1 month ago

Came to report a bug. Got an amazing TypeScript lesson instead. Thanks so much for the super fast response and detailed answer @sinclairzx81!