JSMonk / hegel

An advanced static type checker
https://hegel.js.org
MIT License
2.09k stars 59 forks source link

Objects to unions converter #184

Closed vasilii-kovalev closed 4 years ago

vasilii-kovalev commented 4 years ago

Let's say we have a dictionary:

const FEATURES = {
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering',
};

It can be used like this:

const features = [];

// or it can be a method of a class like "addFeature"
features.push(FEATURES.FILTERING);

I want to say to my features array that it can accept values of the FEATURES object only. But in order to avoid creating a separate union type and support (possible) changes there and in FEATURES object, I'd like to collect values from the object and keep them as a union type. Thus if it is necessary to add/remove value, it can be done in only one place - in the FEATURES object.

To achieve this goal, in TypeScript it is necessary to define such monstrous type:

const FEATURES = {
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering',
// 1. const assertions
} as const;

// 2. if we have a lot of such dictionaries, we have to repeat this stuff every time
type Feature = (typeof FEATURES)[keyof typeof FEATURES];

// if we want to get union type of keys
// type FeatureKeys = keyof typeof FEATURES;

const features: Feature[] = [];

// OK
features.push(FEATURES.FILTERING);

/*
  Should throw a type error
  Argument of type '"foo"' is not assignable to parameter of type
  '"Sorting" | "Filtering" | "Reordering"'.
*/
features.push('foo')

Are there any thoughts of optimizing this type generation to avoid repeating such boilerplate in Hegel? Like a help function or something.

And I'd like to know if it is a common thing to work with dictionaries this way or not. Because I'm new to strong type checking and maybe I think the wrong way here...

Thanks in advance.

thecotne commented 4 years ago
const FEATURES = Object.freeze({
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering'
})

type Feature = $Values<$TypeOf<FEATURES>>

const features: Array<Feature> = []

void features.push('Filtering') // ok
void features.push(FEATURES.FILTERING) // ok

void features.push('foo') // error as expected

try

only down side would be that FEATURES will be frozen on runtime as well (this may or may not be desired behavior)

thecotne commented 4 years ago

if you don't like to freeze object on runtime you can create helper immutableId function that will refine type but don't change value for runtime

const immutableId = <T>(v: T): $Immutable<T> => v

const FEATURES = immutableId({
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering'
})
vasilii-kovalev commented 4 years ago

@thecotne, thanks for your examples. It makes the code a little bit easier, but the problem actually is in Feature type definition repetition. I tried to apply immutableId approach to Feature, but failed of course :)

I tried to get something like this:

// without output type because have no ideas how to describe it
const makeUnion = (obj: object) => $Values<$TypeOf<obj>>;

type Feature = makeUnion(FEATURES);

but it seems that type definition doesn't work this way :)

thecotne commented 4 years ago

this works perfectly fine you don't need to repeat anything ...

const immutableId = <T>(v: T): $Immutable<T> => v

const FEATURES = immutableId({
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering'
})

type Feature = $Values<$TypeOf<FEATURES>>

const features: Array<Feature> = []

void features.push('Filtering') // ok
void features.push(FEATURES.FILTERING) // ok

void features.push('foo') // error as expected

try

you just need to use either Object.freeze or immutableId to achieve same result as as const in typescript.

vasilii-kovalev commented 4 years ago

@thecotne, thanks for your reply!

Actually I found out the answer to my initial question (with your help and my own research). Here is code snippets with the solutions:

For Hegel:

const FEATURES = Object.freeze({
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering',
});

// "Array" should be outside
// type Values<T> = Array<$Values<T>>;

// 2. Define this type
// Array<'Filtering' | 'Reordering' | 'Sorting'>
const features: Array<$Values<$TypeOf<FEATURES>>> = [];

// OK
void features.push(FEATURES.FILTERING);

/*
  Throws a type error
  Argument of type '"foo"' is not assignable to parameter of type
  '"Sorting" | "Filtering" | "Reordering"'.
*/
void features.push('foo');

For TypeScript:

// 1. Freeze the object
const FEATURES = {
  SORTING: 'Sorting',
  FILTERING: 'Filtering',
  REORDERING: 'Reordering',
} as const;

type Values<T> = Array<T[keyof T]>;

// 2. Define this type
// ("Sorting" | "Filtering" | "Reordering")[]
const features: Values<typeof FEATURES> = [];

// OK
void features.push(FEATURES.FILTERING);

/*
  Throws a type error
  Argument of type '"foo"' is not assignable to parameter of type
  '"Sorting" | "Filtering" | "Reordering"'.
*/
void features.push('foo');

The answer I tried to find out is Values type implementation to reduce code repetitiveness. For any reason Array<T> can't be moved to Values type in Hegel unlike TypeScript though. If it is a known issue, I suppose this one can be closed.

thecotne commented 4 years ago

@vasilii-kovalev i have reported Values type issue separately https://github.com/JSMonk/hegel/issues/269