Búsqueda de sitios web

Tipado basado en subconjuntos de uniones en TypeScript


Estoy trabajando en una biblioteca de TypeScript (aquí está) para tipos de datos algebraicos (o como uno quiera llamarlos), y estoy teniendo dificultades con algunos de los tipos más complejos.

Los tipos de datos algebraicos funcionan así:

// Create ADT instantiator
const value_adt = adt({
    num: (value: number) => value,
    str: (value: string) => value,
    obj: (value: object) => value,
});

// Extract union of variants
type ValueADT = Variants<typeof value_adt>;

// 'ValueADT' will be typed as:
// { [tag]: "num", value: number } |
// { [tag]: "str", value: string } |
// { [tag]: "obj", value: object }

// Create instance of 'value_adt'
const my_value = value_adt.str("hello") as ValueADT;

Y he creado una función match (sí, esto está muy inspirado en Rust) para hacer coincidencias y extraer datos asociados de la variante coincidida, que funciona así:

match(my_value, {
    num: value => console.log(`number: ${value}`);
    str: value => console.log(`string: ${value}`);
    obj: value => console.log(`object: ${value}`);
});

La tipificación de parámetros para los matchers proporcionados funciona de maravilla. Sin embargo, he añadido la opción de omitir uno o más matchers y reemplazarlos con un caso por defecto:

match(my_value, {
    num: value => console.log(`number: ${value}`);
    [def]: value => console.log(`not number: ${value}`);
});

He logrado tipar el parámetro del comparador por defecto como la unión de los tipos de valor de todas las variantes (ValueADT["value"] en este caso), pero eso solo tiene sentido realmente si se omiten todos los comparadores y se reemplazan con el comparador por defecto.

Me gustaría mucho poder tipar el parámetro de valor del comparador por defecto basándome en qué otros comparadores se proporcionan, por ejemplo, debería ser string | object en el ejemplo anterior, no number | string | object., así que mi pregunta es qué (si es que hay) hechicería avanzada de TypeScript está disponible para lograr algo así?

Aquí está el enlace al código fuente y tipos de matcher tal como está.

Nota:
No estoy seguro sobre el título de esta pregunta, se aceptan sugerencias.

Actualización:
Aquí hay un código autónomo que ilustra el comportamiento no deseado, es decir, que el tipo de parámetro del comparador predeterminado es una unión que contiene number, lo cual lógicamente no puede ser:

type Variant<T, U> = { tag: T, value: U };

const adt = { tag: "num", value: 1 } as
    | Variant<"num", number>
    | Variant<"str", string>
    | Variant<"obj", object>;

type MatchAll<T extends Variant<string, any>> = {
    [K in T["tag"]]: T extends Variant<K, infer V>
        ? (value: V) => any
        : never
};

const def = Symbol("[def]ault matcher");

type Matchers<T extends Variant<string, any>> =
    | MatchAll<T>
    | Partial<MatchAll<T>> & {
        [def]: (value: T["value"]) => any;
    };

function match<
    T extends Variant<string, any>,
    U extends Matchers<T>,
>(
    variant: T,
    matchers: U,
) {
    const matcher = matchers[variant.tag as keyof U];

    if (matcher === undefined) {
        throw new Error(`No match for '${variant.tag}'!`);
    }

    matcher(variant.value);
}

match(adt, {
    num: value => console.log(`number: ${value}`),
    [def]: value => console.log(`not number: ${value}`),
});

...y un enlace al área de juegos.

Actualización 2:
Después de que se respondió esto, también logré inferir correctamente el tipo de retorno de match (devuelve el valor retornado por el matcher llamado). Esto es un poco aparte con respecto a lo que pregunté aquí, pero podría ser interesante para cualquiera que se encuentre con esto en el futuro: playground.

Respuesta #1

En lo que sigue, me preocupo únicamente por los tipos, y solo desde el punto de vista de los llamadores de match(); la implementación puede necesitar aserciones de tipo o algo similar para evitar errores del compilador, ya que las firmas de llamadas genéricas complicadas tienden a ser difíciles de verificar.


Mi inclinación aquí sería sobrecargar match() de manera que el caso completamente exhaustivo de "coincidir todo" se maneje por separado del caso de "parcial" con coincidencias por defecto.

Para el caso "parcial", queremos que match() sea genérico no solo en T, el tipo unión del argumento variant, sino también en K, las claves del argumento matchers. Y de igual manera, podemos hacer que Matchers sea genérico tanto en T como en K, para que podamos representar el tipo de argumento para todos los métodos de la manera más precisa posible.


Entonces, aquí hay una posible definición de Matchers<T, K>:

type Matchers<T extends Variant<string, any>, K extends PropertyKey> = {
    [P in K]: (value: P extends typeof def ?
        Exclude<T, { tag: Exclude<K, typeof def> }>["value"] :
        Extract<T, { tag: P }>["value"]
    ) => any };

La idea es que mapeamos sobre cada elemento P de K para obtener callbacks que devuelvan any. Para encontrar el tipo de parámetro value del callback con clave P: si P es el tipo de def, entonces value corresponde a todas las variantes no mencionadas en K (que construimos usando el tipo de utilidad Exclude<T, U>). De lo contrario, asumimos que P es una de las etiquetas de T, en cuyo caso value corresponde al valor para la variante con esa etiqueta (que construimos usando el tipo de utilidad Extract<T, U>).

Tenga en cuenta que si K resulta ser la unión completa de T["tag"], entonces la función de devolución de llamada [def] tendría un argumento value de tipo never. Esto puede ser un poco extraño, pero probablemente inofensivo. Si realmente importa, podría cambiar este tipo para que toda la propiedad sea de tipo never en lugar de solo el argumento de la devolución de llamada.


Y aquí están nuestras firmas de llamada sobrecargadas:

declare function match<T extends Variant<string, any>, K extends T["tag"] | typeof def>(
    variant: T, matchers: Matchers<T, K | typeof def>
): void;

declare function match<T extends Variant<string, any>>(
    variant: T, matchers: Matchers<T, T["tag"]>
): void;

La primera firma maneja el caso "parcial" con la propiedad def. La inferencia es un poco complicada aquí; descubrí que necesitaba especificar | typeof def tanto en la restricción para K, como dentro del segundo argumento de Matchers. De lo contrario, el compilador tendería a "rendirse" al inferir K a partir de las claves reales del argumento matchers.

La segunda firma maneja el caso de "coincidir todo" sin la propiedad def, y no necesita ser genérica en K (ya que siempre será solo la unión completa T["tag"]).


Vamos a probarlo contra:

declare const adt:
    | Variant<"num", number>
    | Variant<"str", string>
    | Variant<"dat", Date>;

Primero, probemos el caso de "coincidir todo":

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    str: v => v.toUpperCase()
});

Se ve bien. El compilador conoce los tipos de v en cada callback. Ahora dejemos fuera la clave str, y añadamos la clave [def]:

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    [def]: v => v.toUpperCase()
});

También se ve bien; el compilador sabe que v tiene que ser string en el comparador predeterminado. Ahora vamos a omitir también la clave dat:

match(adt, {
    num: v => v.toFixed(),
    [def]: v => typeof v === "object" ? v.toISOString() :
        v.toUpperCase()
});

Aún bien; el tipo de v ahora es Date | string. Y finalmente, tengamos solo el comparador predeterminado:

match(adt, {
    [def]: v => typeof v === "number" ? v.toFixed() :
        typeof v === "object" ? v.toISOString() :
            v.toUpperCase()
})

El tipo de v es la unión completa number | Date | string. Bien, ahora intentemos incluir todas las etiquetas y el comparador predeterminado:

const assertNever = (x: never): never => { throw new Error("Uh oh") };

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    str: v => v.toUpperCase(),
    [def]: v => assertNever(v)
});

Eso también está bien, supongo. El comparador predeterminado es aceptado, y v es de tipo never (ya que nunca esperamos que se llame al comparador predeterminado).

Cometamos errores y veamos qué dice. Si omitimos str y el comparador por defecto, obtenemos un error:

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
}); // error!
// No overload matches this call.
// Overload 1: Property '[def]' is missing
// Overload 2: Property 'str' is missing

El error describe cómo no hemos llamado correctamente a ninguna de las dos firmas de llamada, y que falta ya sea [def] o str. Si escribimos mal una de las claves:

// oops
match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    strr: v => v.toUpperCase(), // error, 
    //strr does not exist, Did you mean to write 'str'?
})

El error describe cómo no espera ver esa clave mal escrita y (dependiendo de lo cercana que esté nuestra ortografía) sugiere la corrección.


Así que ahí lo tienes, al agregar una segunda firma de llamada que es genérica en las claves del matcher, podemos obtener el tipo de inferencia para el argumento de callback por defecto que estás buscando.

Enlace de Playground al código