paarthenon / variant

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

Variant 3.0 #17

Open paarthenon opened 3 years ago

paarthenon commented 3 years ago

I've begun work on version 3.0 which will be a mild to moderate rewrite of some of variant's core.


Changes now accessible with variant@dev


Changes

Name swap.

Right now the variant() function is used to construct a single case, while variantModule() and variantList are used to construct a variant from an object or a list, respectively. This leads to the function that I almost never use (single case of a variant) holding prime real estate. To that end, variant() is becoming variation() and the variant() function will be an overload that functions as both variantModule and variantList. This is a notable breaking change, hence the tick to 3.0.

Less dependence on "type"

The type field is used as the primary discriminant. This is causing an implicit divide in the library and its functionality as I tend to write things to initially account for "type" and then generalize. However, this is not ideal. My plan is to take the approach with variant actually being variantFactory('type').

The result will be an entire package of variant functions specifically working with tag or __typename.

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

// simply change the key type.
export const {isType, match, variant, variation} = variantCosmos({key: '__typename'});

This should handily resolve #12 .

Better documentation and error handling (UX as a whole)

I've received feedback that larger variants can be tough to read when missing or extraneous cases are present. I will be using conditional types and overloads to better express more specific errors.

I will also be rewriting the mapped types so that documentation added to the template object of variantModule will be present on the final constructors.

Assorted cleanup

This will officially get rid of partialMatch now that it's no longer necessary. I'm not sure, but lookup may go as well. It's on the edge of being useful. I personally almost never use it. I've thought about allowing match to accept single values as entries but am worried about ambiguity in the case where a match handler attempts to "return" a function. How is that different from a handler branch, which should be processed.

Also I will probably be removing the default export of this package (the variant function). Creating a variant, especially with the associated type, involves several imports by itself. What would be the point of a default export? Perhaps variantCosmos, but even that is a bit sketchy.

I finagled with the match types and now the assumed case is that match() will be exhaustive. There is an optional property called default, but actually attempting to use said property moves to the second overload, flipping expectations and making default required and every other property optional. This results in a better UX because as an optional property default is at the bottom of the list when entering names for the exhaustive match.

paarthenon commented 3 years ago

matchLiteral will also be removed, replaced with an onLiteral helper.

Instead of calling a separate function, simply call the helper inside the match expression.

declare const animal: Animal;

const aniType = animal.type; // type: 'cat' | 'dog' | 'snake';

const result = match(onLiteral(aniType), {
    cat: ({type}) => type,
    default: constant(6),
});
paarthenon commented 3 years ago

I've made progress on improved error reporting. Here is the message regarding missing keys on the matcher's complete() function.

image

paarthenon commented 3 years ago

I've put effort into a new generics implementation. The interface is much improved!

Instead of needing to call a separate function and store the object in two different variables, we can simply use the helper function onTerms() that makes variant() generic-aware.

By that same token, GTypeNames is no longer necessary.

Examples

Option\<T>

The option (a.k.a. maybe) type is very simple to define and use.

const Option = variant(onTerms(({T}) => ({
    Some: payload(T),
    None: {},
})));
type Option<T, TType extends TypeNames<typeof Option> = undefined>
    = GVariantOf<typeof Option, TType, {T: T}>;

const num = Option.Some(4);
const name = Option.Some('Steve');

As you might expect, name cannot be assigned to num, as the types of payload will not match.

It is just as elegant to write the extract function:

image

Note that I did not annotate the return type. TS will infer from the match statement.

Tree\<T>

Trees will need to be defined type-first like all recursive variants.

const Tree = variant(onTerms(({T}) => {
    type Tree<T> =
        | Variant<'Branch', {payload: T, left: Tree<T>, right: Tree<T>}>
        | Variant<'Leaf', {payload: T}>
    ;
    return {
        Branch: fields<{left: Tree<typeof T>, right: Tree<typeof T>, payload: typeof T}>(),
        Leaf: payload(T),
    }
}));
type Tree<T, TType extends TypeNames<typeof Tree> = undefined> 
    = GVariantOf<typeof Tree, TType, {T: T}>;

but otherwise follow the same process.


const binTree = Tree.Branch({
    payload: 1,
    left: Tree.Branch({
        payload: 2,
        left: Tree.Leaf(4),
        right: Tree.Leaf(5),
    }),
    right: Tree.Leaf(3),
})

function depthFirst<T>(node: Tree<T>): T[] {
    return match(node, {
        Leaf: ({payload}) => [payload],
        Branch: ({payload, left, right}) => {
            return [payload, ...depthFirst(left), ...depthFirst(right)];
        }
    })
}

const [d1, d2, d3, d4, d5] = depthFirst(binTree);
expect(d1).toBe(1);
expect(d2).toBe(2);
expect(d3).toBe(4);
expect(d4).toBe(5);
expect(d5).toBe(3);
paarthenon commented 3 years ago

Variant constructors and variants themselves now follow behavior that I want to call monadic, but I don't want to fight pedants, so let's say instead they work like Promises in that a variant is only ever one "level" deep. Now,

variant() can now accept arbitrary variant creators, not just template functions. Since a variant is just an object of these constructors, you can clone the variant Animal via const Animal2 = variant(Animal)

variant(variant(variant({}))) simply evaluates to {}.

variant(augmented(_ => ({nameLength: _.name.length}), Animal) is a cute little one-liner that creates a new variant, based on Animal (so it will have a cat, dog, and snake), that will have a new property called "nameLength" that corresponds to the name of the animal. If I create cerberus as BetterAnimal.dog({name: 'Cerberus'}), the resulting object will have a nameLength: 8 property.

The most common way I expect this to come up is also the simplest. You can now override the friendly name and the underlying type by passing the variation function. For example,

const Animalish = variant({
    dog: variation('DOG', fields<{name: string}>()),
    cat: fields<{name: string, furnitureDamaged?: number}>(),
    snake: (name: string, pattern = 'striped') => ({name, pattern}),
}),

creates a version of Animal much like the original except that the "dog" underlying type is now "DOG".

m-rutter commented 3 years ago

The error reporting on the complete is a very welcome change.

m-rutter commented 3 years ago

I'm wondering if there should be some consolidation between matcher and match. Maybe the matcher API should be the successor of match should be deprecated or removed in v3?

I know the builder pattern can be a little offputting, but I think its the superior API overall.

m-rutter commented 3 years ago

Also with a builder pattern you can remove the "default" variant and rely on an .otherwise(() => someDefaultValue) to handle the wildcard case.

paarthenon commented 3 years ago

Hello Michael! It's good to see you.

I'm wondering if there should be some consolidation between matcher and match. Maybe the matcher API should be the successor of match should be deprecated or removed in v3?

Funny you should say that. I've been spending time trying to consolidate the APIs, but at the moment my suspicion is that it won't work out so cleanly. First I tried to add a similar sort of VariantError approach to the match() function, but ran into issues. I attempted it as another overload, but that didn't yield good resultsβ€”the error appeared as you might expect, but it was nested inside of a larger No overload matches this call error that made it difficult to scan the dump and find the relevant issue. I looked at retooling match to be a more complex single interface with no overloads, but I couldn't quite manage that without abandoning some existing features and making documentation worse.

I actually got it working by returning a VariantError but I am not sure if that's good enough. I worry that it may make debugging more difficult as match is so often the item used in a return statement or inside jsx. Functions that have an implicit return type would raise an error some distance from the actual problem, though I suppose an informative error message may assuage that concern.

I know the builder pattern can be a little offputting, but I think its the superior API overall.

Hehe, yeah, I think you might have been there when Retsam complained about my little sample on discord. I guess to be fair, it is slightly less clean when used inline:

{matcher(thing).when({
    caseOne: _ => {},
}.complete())
{match(thing, {
    caseOne: _ => {},
})}

I know the builder pattern can be a little offputting, but I think its the superior API overall.

I like the builder pattern too! It was definitely the most sensible way to support multi-match which gives it a leg up on the competition (and the regular match). Lack of brevity aside, I think the downside of the builder is that when using an object in the .when() call, compiler services cannot offer autocomplete for keys (at least, not past the first type). While using match() on the other hand, I have the experience of handling a variant by matching against it and typing ctrl+enter to iteratively resolve each case. The default keyword became rather intrusive when typing terms that started with a letter after 'd', but should not longer be an issue in 3.0.

However, the .when() calls that correspond to a single term .when('type', ({payload}) => {}) or array .when(['type1' 'type2'], ({payload}) => {}) don't have this autocomplete issue. So especially if you don't use the object syntax, the matcher is probably just better :).

on 'default'

Also with a builder pattern you can remove the "default" variant and rely on an .otherwise(() => someDefaultValue) to handle the wildcard case.

That's true! Has default been causing you difficulties? I thought it would be relatively safe since it's a keyword and wouldn't likely collide. Now that it's considered optional until used, it should do a much better job of staying out of the way. I was actually considering adding it to the matcher to enable the same kind of ergonomics, but I may not if you have negative feedback on that. With 3.0,

I finagled with the match types and now the assumed case is that match() will be exhaustive. There is an optional property called default, but actually attempting to use said property moves to the second overload, flipping expectations and making default required and every other property optional. This results in a better UX because as an optional property default is at the bottom of the list when entering names for the exhaustive match.


I hesitate to post this as a suggested improvement because it's not very different from the matcher, but I'm playing around with a prematch function that will create a handler on a given type ready to take in some input. This may allow error reporting similar to .complete() because in much the same way, the handler is built before the actual call is evaluated. Instead of the .complete() function erroring, it will be when the handler is passed a specific instance to evaluate. That should place the error a bit closer to the problem. This only widens the pool of options, though, so I'll be evaluating how useful it ends up being.

I hope some of that was interesting. Thanks for your comments! I may be overlooking some options, I'd welcome any suggestions.

mikecann commented 3 years ago

Hey I have been using your experimental code shared in https://github.com/paarthenon/variant/issues/12 for some time now and it has been working great.

I have been thinking tho that it might be great if we could get a "point free" / curryed version of the match function so that we can simplify promise callbacks.

e.g:

const { match } = matchImpl(`kind`);

export const matchKind = match;

const fetchUser = async (id: string): Promise<Response> => {
 // ...
}

fetchUser("abc123").then(match({
  "success": ({ user })  => {},
  "error" ({error}) => {}
}))

rather than

fetchUser("abc123").then(resp => match(resp, {
 "success": ({ user })  => {},
 "error" ({error}) => {}
})

Thoughts?

NOTE: Edited the example code because it was incorrect, see one of my posts below.

paarthenon commented 3 years ago

I have been thinking tho that it might be great if we could get a "point free" / curryed version of the match function so that we can simplify promise callbacks.

Good timing @mikecann - I added a function called prematch() to variant 3.0.0-dev.16 published last night (variant@dev). It may meet your needs. Here's how it's used.

EDIT: I see what you mean now. This probably will not help you. It will allow you to define a matchUser that you can then put there, but if you want a version of match that automatically constrains the handler type I'll need to take a sec. I have left the prematch notes up just for documentation. This concept should be viable, it also seems great for .map() calls. I'll see if I can have something for you to try tomorrow.


prematch notes.

const describeAnimal = prematch(Animal)({
    dog: ({name, favoriteBall}) => `${name} is playing with their ${favoriteBall} ball`,
    cat: ({name}) => `${name} is resting on the windowsill`,
    snake: ({name, pattern}) => `${name} is warming his ${pattern} skin against the light`,
});

const cerberus = Animal.dog({name: 'Cerberus', favoriteBall: 'red'});
const description = describeAnimal(cerberus);
// ^ "Cerberus is playing with their red ball"

The call to prematch() has two forms - the variant definition can be passed as a parameter or the union type can be used as a generic term with no parameters.

const matchAnimal1 = prematch(Animal);
const matchAnimal2 = prematch<Animal>();

matchAnimal1 and matchAnimal2 are equivalent. Use whichever syntax you prefer or is most convenient at the time. I expect most people will use the generic interface because importing types can be free in some cases while importing objects will always create a dependency. How does that look to you?

mikecann commented 3 years ago

@paarthenon interesting! Really happy to see you are still working on this.

So if I understand correctly I dont think my example above would be possible? I just relised my above example was incorrect. I have now edited it:

const { match } = matchImpl(`kind`);

export const matchKind = match;

const fetchUser = async (id: string): Promise<Response> => {
 // ...
}

fetchUser("abc123").then(match({
  "success": ({ user })  => {},
  "error" ({error}) => {}
}))

I think what im looking for is the reverse of what you have so match function sig needs to look like:

const match = (handler) => (object) => { ... }

That way it can do this without having to create a "prematcher"

fetchUser("abc123").then(match({
  "success": ({ user })  => {},
  "error" ({error}) => {}
}))
paarthenon commented 3 years ago

So if I understand correctly I dont think my example above would be possible?

It is possible, just not with that function. I got it working, I was actually writing the message but had to jump on a call. Later tonight (originally said in a few minutes, but I got on another call...), check npm for the new variant@dev. It should be dev.17 and it will have the new overload.


It's available now in 3.0.0-dev.17.

const renamedAnimals = animalList.map(match({
    cat: _ => ({..._, name: `${_.name}-paw`}),
    dog: _ => ({..._, name: `${_.name}-floof`}),
    snake: _ => ({..._, name: `${_.name}-noodle`}),
}));
mikecann commented 3 years ago

Sweeeet! Thanks :)

paarthenon commented 3 years ago

I wanted to provide an update. I've been yanked away by my busiest few weeks in years, but there's still been some progress.

I rewrote the matcher. Its code should be much easier to comprehend and contribute to. It's also the first instance of a class within the library. Fun.

Improvements to matcher()

Exhaustive handling

There is a new terminal, .exhaust(), which forces the remaining cases of the matcher to be handled. The matcher will be immediately executed now that the handler is certainly complete.

const describeAnimal = (a: Animal) => matcher(a)
    .when('dog', ({favoriteBall}) => `The dog is playing with their ${favoriteBall}-colored ball.`)
    .exhaust({
        cat: constant('The cat is scratching their post'),
        snake: constant('The snake is sunning their skin.'),
    })
;

As a terminal, it also enables a better inline experience when used from the get-go.

<IconButton
    icon={matcher(animal).exhaust({
        cat: _ => '🐱',
        dog: _ => 'πŸ•',
        snake: _ => '🐍',
    })}
    text='...'
/>

Lookup Tables

With the removal of lookup(), the matcher() seemed a perfect place for that functionality.

As a terminal, like exhaust(), it will close out a matcher. Unlike exhaust(), it requires a lookup table rather than a handler object. In many cases this can be a more useful abstraction. Here are the same code samples made better.

const describeAnimal = (a: Animal) => matcher(a)
    .when('dog', ({favoriteBall}) => `The dog is playing with their ${favoriteBall}-colored ball.`)
    .lookup({
        cat: 'The cat is scratching their post',
        snake: 'The snake is sunning their skin.',
    })
;
<IconButton
    icon={matcher(animal).lookup({
        cat: '🐱',
        dog: 'πŸ•',
        snake: '🐍',
    })}
    text='...'
/>

There is a .register() function to set these up ahead of time.

const cuteName = (a: Animal) => matcher(a)
    .register('dog', 'pup')
    .lookup({
        cat: 'kitten',
        snake: 'noodle',
    })

These integrate well with the existing parts of the matcher. Feel free to mix and match calls to .when() and .register().

Class-based variants

Classes have been getting some love recently from the language designers and are preferred coding style by some developers. 3.0 will bring support for classes as parts of a variant definition. This library was built off factory functions, not class constructors. Thankfully, the former can easily wrap the latter.

I've provided a construct() helper function that will allow class definitions wherever variant bodies are required.

const ClassyAnimal = variant({
    dog: construct(class {
        constructor(
            private barkVolume: number;
        )
        public bark() {
            return (this.barkVolume > 5) ? 'BARK' : 'bark';
        }
    }),
    cat: construct(Cat), // predefined
    snake: construct(class {
        public pattern = 'striped';
    })
});

const cat = ClassyAnimal.cat();

const isCat = cat instanceof Cat; // true!

I'm also expanding and rewriting the documentation. Sometime in the next few days or weeks expect to see a version selector pop up in the variant documentation.

paarthenon commented 3 years ago

I'm also expanding and rewriting the documentation. Sometime in the next few days or weeks expect to see a version selector pop up in the variant documentation.

It's up!

https://paarthenon.github.io/variant/docs/next/intro

So far that's the only page that's been written, but I think it already does a better job of highlighting some of the features of this library than the old intro. The new sidebar has my planned structure for the documentation. As you can see, there's a lot more to go into than there was before. I've received feedback that certain features like recursive or generic variants felt "buried", and there were a number of things that were available in the library that I never properly documented. The longer table of contents reflects these adjustments. It will only grow as I flesh out the text.

The code samples are superpowered with shiki-twoslash. Hover over any term to see the type information. The newest versions have smooth docusaurus support. This doesn't currently carry JSDoc comments but that might change in future (shikijs/twoslash#64). I'm using a modified version of rainglow's comrade-contrast and the Monotone's Red color themes for light mode and dark mode, respectively. The color mode selector is in the top-right corner.

I'm also working on a demo project Kind of Super that will show how variant can integrate with a real-world stack and moderately interesting logic. This will be the basis of the tutorial.

I'll keep making updates to the docs and quietly releasing them. I'll post here if there's a major section complete or new functionality update. I welcome any suggestions.

p.s. I know the jokes are dumb. It's been a long week.

mikecann commented 3 years ago

Phenominal work again! I love the incredible amounts of effort you put into the docs, it really helps explain what the value in using Variant is.

I hadnt heard of shiki-twoslash but its very impressive.

Started following Kind of Super updates, looking forward to seeing it when its done :)

mikecann commented 3 years ago

Hey, I was just browsing through some of the 3.0 docs (great work btw) and noticed you are replacing matchLiteral https://paarthenon.github.io/variant/docs/next/new-in-3.0#literals with onLiteral.

Im okay with the change im just not sure on the name of the function onLiteral. "onSomething" tends to confer an event handler in the JS world and as this isnt an event handler it seems a little strange.

Thoughts?

paarthenon commented 3 years ago

I think that's a good point. I'm open to suggestions if you had something in mind. My gut reaction is a one letter change to ofLiteral.

mikecann commented 3 years ago

Ye ofLiteral or toLiteral would be good :)

paarthenon commented 3 years ago

I've added ofLiteral, I'll update the docs in the near future.

Thank you for your feedback and support.

paarthenon commented 3 years ago

I've redone the interface for match. The problem with the existing API is that the error messages suck. If you are missing a case in a match, you see something like this (thanks @m-rutter for bringing this up as an issue)

src/__test__/animal.ts:29:34 - error TS2769: No overload matches this call.
  Overload 1 of 5, '(object: { type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, handler: AdvertiseDefault<Handler<{ ...; } | { ...; } | { ...; }, "type">>): any', gave the following error.
    Argument of type '{ cat: (c: { type: "cat"; name: string; furnitureDamaged: number; }) => "cat"; }' is not assignable to parameter of type 'AdvertiseDefault<Handler<{ type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, "type">>'.
      Type '{ cat: (c: { type: "cat"; name: string; furnitureDamaged: number; }) => "cat"; }' is missing the following properties from type 'Handler<{ type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, "type">': dog, snake
  Overload 2 of 5, '(object: { type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, handler: WithDefault<Handler<{ ...; } | { ...; } | { ...; }, "type">, { ...; } | ... 1 more ... | { ...; }>): any', gave the following error.
    Argument of type '{ cat: (c: { type: "cat"; name: string; furnitureDamaged: number; }) => "cat"; }' is not assignable to parameter of type 'WithDefault<Handler<{ type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, "type">, { ...; } | ... 1 more ... | { ...; }>'.
      Property '[DEFAULT_KEY]' is missing in type '{ cat: (c: { type: "cat"; name: string; furnitureDamaged: number; }) => "cat"; }' but required in type '{ default: (instance: { type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }) => any; }'.

29 match(sample.cerberus as Animal, {
                                    ~
30     cat: c => c.type,
   ~~~~~~~~~~~~~~~~~~~~~
31 })
   ~

  src/match.ts:20:5
    20     [DEFAULT_KEY]: (instance: DefaultTerm) => any;
           ~~~~~~~~~~~~~
    '[DEFAULT_KEY]' is declared here.

This is really obtuse and obfuscates The actual error, Which is buried in the specifics for overload one. The default keyword is a major red herring since it's the last thing the compiler mentions but has nothing to do with the problem - we aren't doing any partial matching. The new error is the simple message about missing properties that you would expect.

src/__test__/animal.ts:29:35 - error TS2345: Argument of type '{ cat: (c: { type: "cat"; name: string; furnitureDamaged: number; }) => "cat"; }' is not assignable to parameter of type 'Handler<{ type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, "type"> | ((t: { ...; } | ... 1 more ... | { ...; }) => Handler<...>)'.
  Type '{ cat: (c: { type: "cat"; name: string; furnitureDamaged: number; }) => "cat"; }' is missing the following properties from type 'Handler<{ type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "cat"; name: string; furnitureDamaged: number; } | { type: "snake"; name: string; pattern: string; }, "type">': dog, snake

29 match2(sample.cerberus as Animal, {
                                     ~
30     cat: c => c.type,
   ~~~~~~~~~~~~~~~~~~~~~
31 })
   ~

To achieve this I moved partial handling to a secondary function call, which removes the heavy overloads and allows TS to disambiguate. This is still as type safe as ever, thanks to types flowing through higher order functions.

const furnitureDamaged = (animal: Animal) => match2(animal, partial({
    cat: _ => _.furnitureDamaged,
    default: _ => 0,
}));

This makes it more opt-in, and creates room for other match functions. For example, table(), which matches against a lookup or reference table.

const cuteName = match2(animal, table({
    cat: 'kitty',
    dog: 'pupper',
    snake: 'snek',
}));

Maybe this should be called lookup for parity with matcher, but I remember being happy when I deleted lookup() originally so it would stop having name conflicts with node's built-in dns lookup function, and it would be a shame to return to that life. Maybe that can become table, or maybe I'll accept that you can't dodge all name conflicts and call it lookup. User feedback would certainly be helpful in this decision.

As the name might imply, this implementation is available for testing via match2. I'm going to put it through its paces, and based on those results and any feedback I'll switch things over and drop the '2'. This is available for testing now in variant@3.0.0-dev.21

p.s. This does retain the point-free overload. That one was sufficiently distinct.


Updates have been a little bit slower recently due to a wrist injury. I'm seeing a doc this week, I'm hoping that will help.

tonivj5 commented 3 years ago

Really impressive what are you doing here! Awesome lib πŸ‘πŸ»πŸ‘πŸ»

paarthenon commented 3 years ago

The regular match() function has been upgraded to the new interface with dev.22. This is a much simpler set of functions, leveraging the composability of helpers to achieve a cleaner interface with actually useful error messages.

Argument of type '{ cat: (_: { type: " ....
     Property 'snake' is missing in type '{ cat: (_: { type: ...

Waffling over lookup() was probably silly, and the function briefly called table is now just called lookup.

Revisiting ofLiteral()

Now that the overloads are simpler, it opened the room for complexity in other ways. I've considered for some time letting match() accept a literal directly. I've been of two minds on this, but there were a couple of things that tipped the scales in favor.

  1. Typing something like match(ofLiteral(thing), lookup({...})) felt a little gauche, especially because you can type a lookup table of any keys trivially with Record<typeof thing, ...>. That doesn't give you return types of the branches, but it does if you wrap it in an identity function. HOI is already in the library for this purpose.
  2. Now that the inline overload exists, allowing it to accept literals would open the door to stuff like this:

    const greeks = [
        'alpha',
        'beta',
        'gamma',
    ] as const;
    
    const greekLetters = greeks.map(match(lookup({
        alpha: 'A',
        beta: 'B',
        gamma: 'Ξ“',
    })))
    
    expect(greekLetters[0]).toBe('A');
    expect(greekLetters[1]).toBe('B');
    expect(greekLetters[2]).toBe('Ξ“');

    which tickles me. This also will work with promises containing literals.

So you basically don't need ofLiteral() anymore. Any fans of that function should speak up. I would consider it an improvement. It also encourages the use of catalog(), now that its a bit more integrated into the base functionality. Catalog isn't anything revolutionary, but I like the idea of variant as a library that can cover the gamut of domain modeling tools with a minimal and thought out interface.


Thanks for dropping by, @tonivj5. Glad it's working out for you!

mikecann commented 3 years ago

very cool @paarthenon! I like the simplification.

My only slight concern is what it does to TS type check time, hopefully not too bad :)

BTW I just tried the v2.1 version of lookup() and noticed that the return type is "string" rather than a union of literals. Is this something that is fixed in the new version of lookup()?

paarthenon commented 3 years ago

I compared the new implementation to the old one and didn't find a difference in a trial loop, but that will only check runtime performance. I'll check out the options for auditing type performance. In the meantime, I'll be testing before release with progressively larger unions to confirm that things won't suffer.

BTW I just tried the v2.1 version of lookup() and noticed that the return type is "string" rather than a union of literals. Is this something that is fixed in the new version of lookup()?

Try with as const.

3.0 screenshot

mikecann commented 3 years ago

Try with as const.

Ah yes of course :) Thanks

paarthenon commented 2 years ago

Hello folks, there's a new batch of changes. These should be available in variant@dev, version 3.0.0-dev.24.

I gave matcher() the same treatment, so it has support for literals directly.

While I was there, I adjusted a couple of things. The overloads for .when() were competing for priority, so I deprecated the .when({...}) object overload and moved that functionality over to .with({...}), more closely resembling the inspiring match syntax.

const greekLetters = greeks.map(letter => matcher(letter)
    .with({
        alpha: _ => 'A',
        beta: _ => 'B',
        gamma:_ => 'Ξ“',
    })
    .complete()
);

Now that these overloads no longer conflict, the .when() function should actually grant autocompletion as you enter the list of types.


The isOfVariant() function has been given the same secondary overload as isType, such that it can now be used in .filter().

const animals = [
    Animal.dog({name: 'Cerberus'}),
    Animal.cat({name: 'Perseus', furnitureDamaged: 0}),
];

const isAnimalList = animals.map(isOfVariant(Animal));

I extended the new interface of match (favoring the helper functions) over to the prematch() function. It can now benefit from the same utility functions like partial and lookup.


The documentation has been improved. I fleshed out more of the "Inspection" page and wired up the API to use TypeDoc. It will now automatically update with the library doc comments.

In my next updates, I'll experiment with categories in TypeDoc, and attempt to have more of the documentation (the organization and the matching sections) fleshed out. I hope to have things ready for a 3.0.0 alpha in the coming weeks. The "Kind of Super" updates will probably happen after that. I'm eager to do them, but I still have limited typing and I should probably focus on the core functionality first.

mikecann commented 2 years ago

Hey @paarthenon its been a long time since I have checked in. Hows progress on v3.0 ?

paarthenon commented 2 years ago

Hey @mikecann thanks for stopping by. Progress on 3.0 was halted for some time. I ran into some serious difficulties with my health and have been unable to work on the project. In the past few weeks, I've started typing again. Now, I'm cautiously optimistic about my ability to return to things. I participated in a game jam this weekend, and while it was definitely taxing, the fact that it was possible speaks more to my recovery than anything else I've done.

I actually used 3.0 as a core part of what I built this weekend and had a smooth experience. The essential functionality is likely ready for me to push to alpha. My standards were a little too high back when I expected to have an infinite road of typing in front of me.

By essential functionality I mean variant creation (fields, payload, custom functions), matching, and so on. The things that have been holding me back have been:

I'm taking stock, seeing how important these things actually are, and we'll move forward soon. Another user has kindly offered their assistance with the project, and I'm creating more of these things as issues with information others can leverage so that I'm not the big bottleneck anymore.

mikecann commented 2 years ago

Awesome thanks @paarthenon for all of that.

Sorry to hear about the health issues mate, that really sucks particularly when you seemed to be steaming ahead just wonderfully.

Ye its up to you when you want to release v3.

Create a good solution for deploying kind and __typename. The implementation works! Now it's just a question of import UX and bundle size.

I personally definately will require custom discriminator names because I like to use kind instead of type, but thats just me being selfish ;)

Your docs are definately one of the best bits of Variant so I think those and the basic matching are the most important things to me.

No rush tho, health is number one priority :)

paarthenon commented 2 years ago

Changes available in 3.0.0-dev.25.

I've added a withFallback helper to address #24. It takes two parameters, a completed handler object and a fallback function. As these cases are exceptional, I left the fallback function's input as unknown.

export const calculateIfBattleWasAbandoned = (reason: BattleFinishedReason): boolean =>
    match(reason, withFallback({
        timeout: () => true,
        surrender: () => true,
        attack: () => false,
    }, () => false))

For matcher() rather than adding it as a new terminal, I expanded .complete() to include this fallback functionality, since I expect users would still want exhaustiveness. Here's an example of that in use:

export const calculateIfBattleWasAbandoned = (reason: BattleFinishedReason): boolean =>
    matcher(reason)
        .when(['timeout', 'surrender'], () => true)
        .when('attack', () => false)
        .complete({
            withFallback: () => false
        })

Ideally it would also be possible to use this sort of fallback handling alongside exhaustiveness checking. The complete() function provides this with type magic, but sometimes it's nice to be able to turn your brain off and fill out the required branches of a handler object one-by-one. To that end, I've added a .remaining() function to matcher which takes a handler with branches for the rest of the possible variants.

export const calculateIfBattleWasAbandoned = (reason: BattleFinishedReason): boolean =>
    matcher(reason)
        .when('attack', () => false)
        .remaining({  // both properties required
            timeout: () => true,
            surrender: () => true,
        })
        .complete({
            withFallback: () => false
        })
mikecann commented 2 years ago

but sometimes it's nice to be able to turn your brain off and fill out the required branches of a handler object one-by-one.

Also some IDEs are able to auto-fill the objects based on the types of the keys :)

Nice work on this, really looking forward to switching over to v3 when its out.