Giter Site home page Giter Site logo

paarthenon / variant Goto Github PK

View Code? Open in Web Editor NEW
177.0 5.0 3.0 6.92 MB

Variant types in TypeScript

Home Page: https://paarthenon.github.io/variant

License: Mozilla Public License 2.0

TypeScript 90.15% JavaScript 7.81% CSS 2.04%
type-theory variants discriminated-unions dispatching redux flux variant typescript union algebraic-data-type

variant's Introduction

Variant Build Status npm NPM

A variant type is like an enum but each case can hold some extra data.

npm i -S variant

Variant aims to bring the experience of variant types to TypeScript. Variant types, a.k.a. discriminated unions in the TypeScript world, are an excellent tool for describing and handling flexible domain models and tiny DSLs. However, because "TypeScript instead builds on JavaScript patterns as they exist today" using them as-is can result in tedious and fragile code. This project addresses that by providing well-typed, fluent, and expressive tools to safely do away with the boilerplate.

✨ Variant 3.0 is in active development. Documentation featuring 3.0 features will be available shortly. In the meantime, the changes are available here.

everything below this line is project documentation for developers. Please use the website linked just above.


Initial setup

npm install

build

npm run build

test

npm test

Questions, comments, and contributions are welcome. Open an issue.

variant's People

Contributors

cwstra avatar dependabot[bot] avatar paarthenon avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

variant's Issues

Exhaustive AND have a default?

I am still loving Variant and using it on a daily basis so MASSIVE thanks for this awesome lib :)

Up to now Variant has done just about everything I wanted. I have however started to run into a scenario that is causing problems. Its best described with an example.

e.g. I have the following

interface Attack {
  kind: `attack`;
  playerId: string;
}

interface Timeout {
  kind: `timeout`;
  playerId: string;
}

interface Surrender {
  kind: `surrender`;
  playerId: string;
}

export type BattleFinishedReason = Attack | Timeout | Surrender;

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

This is great, I release this code as v1. The server and client are working great.

I now do a v2 of the game and add a new type to the union..

...

interface Cancel {
  kind: `cancel`;
  playerId: string;
}

export type BattleFinishedReason = Attack | Timeout | Surrender | Cancel;

The compiler will now yell at me that there is a missing option in the calculateIfBattleWasAbandoned match which is great, so I add that:

export const calculateIfBattleWasAbandoned = (reason: BattleFinishedReason ): boolean => 
  matchKind(reason, {
    timeout: () => true,
    surrender: () => true,
    attack: () => false,
    cancel: () => true,
  });

I then push the code as a v2 release. The server is happy because it can handle the new case but the problem is there are clients still on v1 of the game and so their calculateIfBattleWasAbandoned matcher doesnt know how to handle the Cancel BattleFinishedReason ..

I know this can be solved with a default option in the matcher:

export const calculateIfBattleWasAbandoned = (reason: BattleFinishedReason ): boolean => 
  matchKind(reason, {
    timeout: () => true,
    surrender: () => true,
    attack: () => false,
    cancel: () => true,

    // Saving future me some headaches
    default: () => false
  });

... but this breaks the exhustive checking which I rely on so much.

So is it possible for the matcher to be both exhaustive AND have a default?

Allow recursive types

I ran into this issue when trying to create types for a lambda calculus interpreter. Repro example:

import variant, { variantList, VariantOf, fields, TypeNames } from "variant";

export const LambdaExpr = variantList([
  variant("Var", fields<{ ident: string }>()),
  variant("Apply", fields<{ func: LambdaExpr; arg: LambdaExpr }>()),
  variant("Lambda", fields<{ argName: string; body: LambdaExpr }>()),
]);

export type LambdaExpr<T extends TypeNames<typeof LambdaExpr> = undefined> = VariantOf<typeof LambdaExpr, T>;

The const declaration has this TypeScript error:

'LambdaExpr' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. ts(7022)

The type declaration has this TypeScript error:

Type alias 'LambdaExpr' circularly references itself. ts(2456)

Is there a way to create recursive variant types that I'm missing? If not, would it be possible to add support for them to this library?

`variantModule` export is missing

Hi, thank you for this very promising library!

I'm trying to follow the example and use the handy variantModule function, but it seems like it's missing?
I'm using the following import statement:
import { variantModule } from 'variant'

The installed library version is 2.0.0.

Thanks :)

custom match creator

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?

lookup vs as const

great library, I am trying it out in my project. Curious, what would be the difference between a lookup() vs just a simple

type Bla = {
 
} as const```

Change the `type` field

I think it could be a good idea to change the type field by another name like __typename or other to follow graphql or other existing codebase

thanks

Tree shaking and bundle size

Evaluate how well the library can be tree-shaken. While the library is already pretty small (3kb gzipped) this would be nice to have, especially since many users are likely unaware of the advanced functionality of Variant (flags/matrix, type-first elements) and if they aren't using them, they might as well not spend the kb.

Other discriminants

I have a sneaking suspicion that variantCosmos, as convenient as it is from a UX perspective, will significantly complicate tree-shaking. Let's find out.

No compiler error from method with variant

Love the library, been using it the last couple days (in gamedev too! saw you mention it)

Ran into a little thing though:

  1. Given a variant module of Animal with a variant Dog
  2. If you create a function that only accepts Dog
  3. And use it point-free in a context that uses Animal
  4. The compiler doesn't raise an error

Variants:

  import { variantModule, VariantOf, TypeNames } from "variant";

  const Animal = variantModule({
    dog: {},
    cat: {}
  });

  type Animal<T extends TypeNames<typeof Animal> = undefined> = 
    VariantOf<typeof Animal, T>;

Issue:

  // an array of Animals
  declare const arrayOfAnyAnimal: Animal[];

  // Bark function for dogs only
  declare function bark(dog: Animal<'dog'>): Animal;

  // (expected): Error: Argument of type 'SumType....
  arrayOfAnyAnimal.map((i) => bark(i));

  // (unexpected): No compiler error
  arrayOfAnyAnimal.map(bark);

I'm wondering if this is fixable, or if there's at least some kind of workout to make the parameter of bark(dog) more strict?

matcher that doesnt require an object

I find myself doing something like this often:

type BattleFinishedReason =
  | `PlayerAttack`
  | `PlayerTimeout`
  | `PlayerSurrender`
  | `PlayerCancelled`;

const   getReasonForBattleFinished = (): BattleFinishedReason  => { ... }

match(
    { type: getReasonForBattleFinished(battle) },
    {
    ...
    }
  );

It would be nice if there was a helper function that lets you just pass in a union to match on without the wrapping type so something like:

matchOn(getReasonForBattleFinished(battle), {
...
});

Thoughts?

Idea: Variation helper type to extract a variant

I've been rereading the docs as I switch over to variant@dev, and have a suggestion in reference to "That type annotation"

Complexity

The type has been a sore spot in using the library. Along with having to find and copy and paste the definition for each variation (I use it everywere), hovering over the type actually does expose a fair bit of complexity:

type Animal<T extends TypeNames<{ 
cat: VariantCreator<"cat", (input: { name: string; furnitureDamaged: number; }) => { name: string; furnitureDamaged: number; }, "type">; 
dog: VariantCreator<"dog", (input: { name: string; favoriteBall?: string | undefined; }) => { name: string; favoriteBall?: string | undefined; }, "type">; 
snake: VariantCreator<...>; }> = undefined> = T extends undefined ? {
    type: "cat";
    name: string;
    furnitureDamaged: number;
} | {} /** (.... plus many more lines) */

Meanwhile the early example of using just VariantOf<typeof Animal> looks like the union that you would expect:

type Animal = {
    type: "cat";
    name: string;
    furnitureDamaged: number;
} | {
    type: "dog";
    name: string;
    favoriteBall?: string | undefined;
} | {
    type: "snake";
    name: string;
    pattern: any;
}

As I'm looking at the docs, it seems the main reason for the complicated type is to enable the type Animal<'dog'>

Suggestion

Make a small helper wrapper around Extract to that simplifies getting variations

type Variation<V extends { type: string }, T extends V["type"]> =
    Extract<V, { type: T }>

Usage:

export type Animal = VariantOf<typeof Animal>
const bark = (dog: Variation<Animal, 'dog'>) => {}

export type Snake = Variation<Animal, 'snake'>

Playground Link

  1. API is very simplified and the type definition looks standard
  2. It still autocompletes for the tag ('dog')
  3. Get an error if the tag does not exist in the variant
  4. (opinion) As a consumer it'd be better to reference a type of Dog instead of Animal<'dog'>

Generic variant usage with function as payload

Hi again :)

I'm trying to create a generic variant:

const [Matcher, __Matcher] = genericVariant(({ T }) => ({
  Specific: payload(T),
  Custom: (payload: (v: typeof T) => boolean) => ({ payload }),
}))
type Matcher<T, TType extends GTypeNames<typeof __Matcher> = undefined> = GVariantOf<typeof __Matcher, TType, { T: T }>

Specific is pretty straight-forward, where Custom accepts a function that needs to use the generic type.

When using Specific in a "wrong" way, I get an expected error:

// error due to `number` payload not matching `string`
const matcher: Matcher<string> = Matcher.Specific(5)

When using Custom in a "wrong" way, I don't get an error:

const matcher: Matcher<string> = Matcher.Custom((v: number) => v === 1)

I'm probably doing something wrong with the variant creation, how can I get the generic type of Custom to work?

Thanks!

Casting between Union Types

Given the examples from the documentation on Subsets and Combinations I have two questions;

  1. Is it possible to narrow union types?
export const AnimalWithWings = variantList([
    Animal.bird,
    Animal.pegasus,
]);
export type AnimalWithWings<T extends TypeNames<typeof AnimalWithWings> = undefined> = VariantOf<typeof AnimalWithWings, T>;
export const AnimalWithFourLegs = variantList([
    Animal.cat,
    Animal.dog,
    Animal.pegasus,
]);
export type AnimalWithFourLegs<T extends TypeNames<typeof AnimalWithFourLegs> = undefined> = VariantOf<typeof AnimalWithFourLegs, T>;

const flap = (animal: AnimalWithWings) => {...} 
const a : Animal = someFunc();

// how can I check that a belongs to AnimalWithWings
  1. Is it possible to define a type union based on a contract?
// instead of explicitly listing all animals
// use the properties and define a contract
export type AnimalWithWings<T extends TypeNames<typeof A>, A extends Animal & {wingCount: number} > ...

I know this type makes no sense, but the idea is to define the type as union all Animal variants that have a wingCount property.

Re-explore scoped variants

Scoped variants, while functional, were created before TS had template literal types. Now that we live in that world, there are most likely better ways to implement scoped variants and more powerful functionality we can include. Perhaps better support for arbitrary separators.

There's also a conversation to be had on what would make the best separators. Some candidates would be

  • ., probably my current favorite. It's clear and concise, but because it's so commonly used to scope things people may already be in the habit of using foo.bar.baz as a key. That would break the functionality intended to separate variant scopes, so another separator might be preferred.
  • ::, classic from C++ and more.
  • | I personally enjoy the aesthetic but more commonly used to pipeline.
  • -
  • /

Move documentation to its own branch

Right now the documentation is pulled from the docs folder of 3.0. While this was convenient at first, it is sloppy.

  • It confuses the documentation commits with the code commits
  • It creates duplication when the project has two main lines of development (3.0 and 2.x).

Documentation can be moved to its own branch. Ideally the documentation itself is the result of some CI process. I believe github workflows are up to the task.

Scoped Variants (/namespaces)

As things are each variant is compared purely based on type value, which means there can be name conflicts if you set up multiple actions.

export const Action = {
    ...AppAction,
    ...RequestAction,
};

AppAction and RequestAction may both contain a LoadTodos action, which will cause a conflict if an action of RequestAction is checked to be isOfType(_____, AppAction) because it will wrongfully return true.

Scoped types (@action/TYPE) are the resolution to this, and TypeScript's 4.1 string templating features make this an ideal feature for this library.


I have already implemented a version of this, but there remains a discussion on which delimiter is most appropriate. Also to allow for users who have not yet updated, TypeScript 4.1-specific features will be released in variant 2.1+.

Expand documentation for the catalog function with integration examples

The catalog function is documented, but only its behavior. Ideally it should also show:

  1. How to use a pre-existing catalog as the basis for a variant's keys. This can be done manually, and likely also with the augment() function.
  2. Why TS's enum implementation is sort of problematic. It's ostensibly opaque, but that opaqueness can be leaky in a way that makes working with string literals the more honest way to go about things.

Variant 3.0

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.

Early 2022 Update

Hello everyone, I want to start by thanking you all for using variant. I'm here to provide a little update. Over the past few months I have been experiencing health issues that have kept me from making the progress I would have liked. I've had to ration my typing, and recently have had to stop altogether. Sadly it's going to have to stay that way for the next few weeks.

I'll need to ask for a bit of your patience during this time. My health has been the primary delay behind 3.0, but I now have access to care and am optimistic about the project's progress upon my return. In the short term, I will be exploring the voice coding solutions that exist like talon. However, this has a steep learning curve so is not quite a drop-in replacement.

The project could benefit from some help. While contributors are and have been welcome, I have not made the path to contribute as easy as I could have. Going forward I will speak more openly in github issues about my plans and ideas, including the items remaining for the 3.0 release. I'll also lay out some information about the structure of the project and where different types of content should go.

I originally wanted to have the information ready for you to publish alongside this statement, but introducing that as a dependency has kept me from making this update for a couple of weeks as I juggled things. Let me say instead that I hope to have that for you soon, and in the mean time I will have voice dictation available to answer questions.

I appreciate your support.

Support for generic values

Hi, thanks for the nice library and extensive documentation. I think one thing missing is when you'd want some variants to contain values with a generic type.

For example, imagine a Fetched type which represents a request fetching some data of type T and can have 3 variants "fetching", "fetched" and "error". Here is some code snippets that doesn't work but just show the idea..:

const Fetched = variantList<Employee>([
  variant("fetching", fields<{ progress: number, previousData?: Employee }>()),
  variant("fetched", fields<{ data: Employee }>()),
  variant("error", fields<{ error: string }>()),
]);
type Fetched<T extends TypeNames<typeof Fetched> = undefined> = VariantOf<
  typeof Fetched,
  T
>;

By the way, I also tried a similar library which you might not know, it seems to require a bit less boilerplate than yours but there are probably other tradeoffs, but it also didn't support generic values: https://github.com/suchipi/safety-match

A good DX for custom discriminants

Custom discriminants (using kind instead of type) are pretty well supported with variantCosmos (see #17).

Now the task becomes—how should we include the most common variants (hehe) without tripling the bundle size?

Followup question—could/should we avoid collisions between that and the default variant exports? As in, if a user wants to leverage their own match function that operates on something other than type, then is it really a good idea for the top-level functions to get in the way?


Off the top of my head, there's the ability to do multiple packages... but I'm actually intrigued by the potential of creating multiple index pages and changing the default assumption to import from those. Basically, remove all the exports from "variant" except for variantCosmos, and then publish everything under subfolders.

import {variant, fields, VariantOf} from 'variant/type';
 
export const Animal = variant({
    cat: fields<{name: string, furnitureDamaged: number}>(),
    dog: fields<{name: string, favoriteBall?: string}>(),
    snake: (name: string, pattern: string = 'striped') => ({name, pattern}),
});
export type Animal = VariantOf<typeof Animal>;

However, this may make it difficult for autocomplete to know what to prioritize 🤷‍♀️. This needs testing. The current world where you import variantCosmos in one file and then have the rest of your app pull from your re-exported members isn't terrible (users tell me if I'm wrong), so this probably needs to be actively good to be worth it.

VariantCreator output hinting for custom discriminants

The VariantCreator functions actually display the type property of the variant they are creating. Each of them have key and type properties that contain the discriminant key string (type/kind) and the discriminant value (Dog/ADD_TODO) respectively.

While this is already useful information to have at runtime, there's a more interesting use. Their type properties mean that the functions themselves are discriminated unions and can be put through a match() or switch statement. By doing so and receiving the specific constructor along the handler branch, we can access varying constructors in a type-safe way.

We can better engage with procedural generation because this gives us the power to randomly select a variant creator and execute custom creation for each case.

The problem, however, is that these properties are hardcoded as key and type. That means our kind users would need to specifically pull in the type version of match to operate on these functions. I would prefer to remove this point of friction.

To do so,

The variant creator function type and implementation must be changed to

  • move the key and type properties to an object property, something like output.key and output.type.
  • add a [<key>]: <type> property that mirrors what the type: <type> property used to do.

async variant

this library at this point has touched all the core parts of our application, we are really enjoy the much stricter type checks with the metadata attached to the different variants, good stuff!

we want to use variant as task definitions, for ex:

const TaskExtractMetadata = variant('extract_metadata', async function(file: File) {
// do async stuff
    const stuff1 = await bla();
     return {
         stuff1
     }
})

then, we can run the task and get a defined shape of the result that we can exhaustively check as we add more tasks.

The issue is, I don't think variant works with async because it immediately returns with type even though the code above did not throw any typescript error. The code above will run and returns {type: 'extract_metadata'} without stuff1

Since the typescript doesnt complain, I may have been using it wrong or this could be a feature request / bug report :)

Require cycle in generic.ts

I get the following warning at startup.
Should be solvable by importing directly instead of from '.'.

Require cycle: node_modules/variant/lib/index.js -> node_modules/variant/lib/generic.js -> node_modules/variant/lib/index.js

Require cycles are allowed, but can result in uninitialized values. Consider refactoring to remove the need for a cycle.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.