paarthenon / variant

Variant types in TypeScript
https://paarthenon.github.io/variant
Mozilla Public License 2.0
182 stars 3 forks source link

custom match creator #12

Closed mikecann closed 3 years ago

mikecann commented 3 years ago

Hey,

Awesome lib, been looking for something like this for a while.

You mention in your (incredible) docs that you can use a different kind of discriminator by passing a second arg to the match function:

const result = match(animal, {
    Cat: ...
    Dog: ...
    Snake: ...
}, '__typename')

Thats cool, I would like to alias that into:

const matchKind = (obj: T, handler: U) => match(obj, handler, `kind`) 

Im finding it tricky to type this function however, do you have a good solution?

paarthenon commented 3 years ago

Hey thank you for your kind words. I'm sorry I haven't responded earlier, I have seen this issue, but things have been rather busy IRL. I hope to look in this weekend. This is possible, but the types might be annoying to write. If It's too ugly I'll look into publishing something like a matchFor helper to improve the experience.

mikecann commented 3 years ago

No worries at all mate, no rush. Just a nice to have.

Ye the types are quite gnarly. I had a 5 min stab at it but I thought I would open an issue incase you had a helper for it somewhere already.

paarthenon commented 3 years ago

Hi @mikecann I've been exploring ways to do this and it was essentially writing the match function again. While this is doable, it spurred a longer nagging point in my head—people probably hate having to toss around their discriminant all the time, and it hurts the library to have this sort of soft assumption about "type".

Fixing this requires some larger changes, but I'm actually a decent way through those changes and it appears to work. Basically I'm giving all of the type-specific functions access to the same closure so they can vary on key: K extends string and use the appropriate property. That way you will have a personal copy of the variant functions meant to handle kind at the same level of fidelity they handle type.

export const {isType, match, variant, types} = variantCosmos({key: 'type'});

I'm working on this as part of #17 . Does that interface seem satisfactory to you?

mikecann commented 3 years ago

@paarthenon oooo! That sounds about perfect yes! :)

The only thing I might be worried about is how much complexity this ads to typescript's compile / typing time. I have seen it multiple times with libraries (MobX State Tree, XState, MobX Keystone and others) that trying to do too much clever typing causes the compiler / type service to grind to a halt.

Having said that, this looks like an awesome addition, would be fantastic to get it in.

paarthenon commented 3 years ago

@mikecann glad to hear it.

Regarding performance, I haven't finished writing it yet so I can't give you a guarantee, but I'm not too concerned about the impact. In terms of runtime performance the variant() function that underlies every variantModule and variantList call already accessed elements from a closure. Concerning types, the match function will manifest with a concrete version of the match type expecting "tag" or "__typename" instead of the current status quo of accepting a K parameter defaulting to "type". Variant 3.0 will also include a refactor of all the base types now that I just know more about the language, which should also speed things up. That said, I will definitely make sure to confirm this in testing between 3.0 and 2.1/2.2 once 3.0 is in a working state. I don't want to degrade performance either :).

If you would like to test this yourself, here's a self-contained sample of the match function I'm writing. This doesn't include proper documentation, but it's a start. Access it with const {match} = matchImpl('type'); then use as usual. If this works for you then you're welcome to hold onto it until 3.0 is ready for outside eyes.


/**
 * A message.
 */
export interface Message<T> { __: never, message: T };

/**
 * Catch-all type to express type errors.
 */
export interface VariantError<T> { __error: never, __message: T };

/**
 * Basic building block, the loose function signature.
 */
export type Func = (...args: any[]) => any;

/**
 * Prevents 'overflow' in a literal.
 */
export type Limited<T, U> = Exclude<keyof T, U> extends never 
    ? T 
    : VariantError<['Expected keys of handler', keyof T, 'to be limited to possible keys', U]>
;

/**
 * A set of functions meant to handle the variations of an object.
 */
 type Handler<T extends Record<K, string>, K extends string> = {
    [P in T[K]]: (instance: Extract<T, Record<K, P>>) => any;
}
type AdvertiseDefault<T> = T & {
    /**
     * Adding a `default` value will make make this a partial match,
     * disabling exhaustiveness checking.
     */
    default?: Message<'Use this option to make the handling optional.'>;
}

type WithDefault<T> = Partial<T> & {
    default: (instance: T) => any;
}

/**
 * Pick just the functions of an object.
 */
type FuncsOnly<T> = {
    [P in keyof T]: T[P] extends Func ? T[P] : never;
}

export type MatchFunc<K extends string> = {
    /**
     * Matchmaker, matchmaker, find me a match.
     * @param object 
     * @param handler 
     */
    match<
        T extends Record<K, string>,
        H extends AdvertiseDefault<Handler<T, K>>,
    >(object: T, handler: H): ReturnType<H[T[K]]>;
    /**
     * Matchmaker I'm desperate find me a partial match.
     * @param object 
     * @param handler 
     */
    match<
        T extends Record<K, string>,
        H extends WithDefault<Handler<T, K>>,
    >(object: T, handler: Limited<H, T[K] | 'default'>): ReturnType<FuncsOnly<H>[keyof H]>;
    /**
     * Matchmaker I'm very specific and I want to enumerate my remaining options.
     * @param object 
     * @param handler 
     * @param elseFunc 
     */
    match<
        T extends Record<K, string>,
        H extends Partial<Handler<T, K>>,
        EF extends (instance: Exclude<T, Record<K, keyof H>>) => any
    > (object: T, handler: Limited<H, T[K]>, elseFunc: EF): ReturnType<FuncsOnly<H>[keyof H]> | ReturnType<EF>;
}

export function matchImpl<K extends string>(key: K): MatchFunc<K> {
    function match<
        T extends Record<K, string>,
        H extends Handler<T, K>,
        EF extends (instance: Exclude<T, Record<K, keyof H>>) => any,
    >(object: T, handler: H, elseFunc?: EF) {
        const type = object[key];

        if (type in handler) {
            return handler[type]?.(object as any); // TODO: Check if ?. is necessary.
        } else {
            if (elseFunc != undefined) {
                return elseFunc(object as any);
            } else if ('default' in handler) {
                return (handler as (H & {default: (instance: T) => any})).default(object)
            }
        }
    }

    return {match};
}
mikecann commented 3 years ago

Awesome!

Ye I think typescript performance often fails when it has to infer big union types which can suffer from combinatorial explosion.

Thanks for sharing a WIP :)

mikecann commented 3 years ago

BTW I had a chance to use your example above and it workds really well! Thanks :)

paarthenon commented 3 years ago

You're very welcome! You can also now access a version of match() that will receive my updates on the variant@dev tag (but as a consequence, is also more volatile). Note this includes the other breaking changes from #17 as well.