Skip to content

Instantly share code, notes, and snippets.

@jRimbault
Last active May 9, 2022 19:23
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jRimbault/c69aecdb0e2e7dd6a554b034ea18a6ed to your computer and use it in GitHub Desktop.
Save jRimbault/c69aecdb0e2e7dd6a554b034ea18a6ed to your computer and use it in GitHub Desktop.
/**
* Every asserts stays true.
*
* This means Storage<{ key: number } & { foo: string }> and Storage<{}> can coexist.
*
* The first is an extension of the second.
* This is trivially true, but "wrong" for this use case.
*
* Is there a better way to achieve the same thing ?
*/
interface Storage<Map extends Record<string, unknown> = {}> {
set<Key extends string, Value>(
key: Exclude<Key, keyof Map>,
value: Value
): asserts this is Storage<Map & { [k in Key]: Value }>;
get<Key extends keyof Map>(key: Key): Map[Key];
// doesn't work
remove<Key extends string>(
key: Key
): asserts this is Storage<{ [K in Exclude<keyof Map, Key>]: Map[K] }>;
// doesn't work
clear(): asserts this is Storage<{}>;
}
declare const store: Storage;
{
store.set("key", 1);
store.set("foo", "");
store.set("bar", { hello: "world" });
const a = store.get("key");
const b = store.get("foo");
store.remove("foo"); // doesn't work
store.clear(); // doesn't work
store.get("foo");
const c = store.get("bar");
}
/**
* This could be solvable with an immutable design.
*
* But JS lacks the move semantics which would enforce correct usage.
* (and/or variable shadowing which would _help_ but not _enforce_)
*/
interface ImmutableStorage<Map extends Record<string, unknown> = {}> {
set<Key extends string, Value>(key: Exclude<Key, keyof Map>, value: Value):
ImmutableStorage<Map & { [k in Key]: Value }>;
get<Key extends keyof Map>(key: Key): Map[Key];
remove<Key extends string>(key: Key):
ImmutableStorage<{ [K in Exclude<keyof Map, Key>]: Map[K] }>;
clear(): ImmutableStorage<{}>;
}
declare const empty: ImmutableStorage;
{
const one = empty.set("key", 1);
const two = one.set("foo", "");
const three = two.set("bar", { hello: "world" });
const a = three.get("key");
const b = three.get("foo");
const four = three.remove("foo");
const five = four.clear();
five.get("foo");
const c = five.get("bar");
}
@SebKranz
Copy link

SebKranz commented Apr 28, 2022

I can't remove the key completely, but I can do Map & { foo: never }

Then we can define a sum-type of all keys that don't map to never:

type KeysExcludingNever<Map extends Record<string, unknown>> = {
  [K in keyof Map]: Map[K] extends never ? never : K
}[keyof Map]

Or more intuitively: make a type like { key: "key", bar: "bar", foo: never }, and then get all its values with [keyof Map]. This would result in "key" | "bar" | never, but never gets filtered automatically.

Now we use this as the constraint for get()

get<Key extends KeysExcludingNever<Map>>(key: Key): Map[Key]

Full code:

type KeysExcludingNever<Map extends Record<string, unknown>> = {
  [K in keyof Map]: Map[K] extends never ? never : K
}[keyof Map]

/**
 * Every asserts stays true.
 *
 * This means Storage<{ key: number } & { foo: string }> and Storage<{}> can coexist.
 *
 * The first is an extension of the second.
 * This is trivially true, but "wrong" for this use case.
 *
 * Is there a better way to achieve the same thing ?
 */
interface Storage<Map extends Record<string, unknown> = {}> {
  set<Key extends string, Value>(
    key: Exclude<Key, keyof Map>,
    value: Value
  ): asserts this is Storage<Map & { [k in Key]: Value }>

  get<Key extends KeysExcludingNever<Map>>(key: Key): Map[Key]

  // doesn't work
  remove<Key extends string>(
    key: Key
  ): asserts this is Storage<{ [K in keyof Map]: K extends Key ? never : Map[K] }>

  // doesn't work
  clear(): asserts this is Storage<{}>
}

declare const store: Storage
{
  store.set('key', 1)
  store.set('foo', '')
  store.set('bar', { hello: 'world' })
  const a = store.get('key')
  const b = store.get('foo')
  store.remove('foo')
  store.clear()
  store.get('foo') // Argument of type '"foo"' is not assignable to parameter of type '"key" | "bar"'.
  const c = store.get('bar')
}

@chriskrycho
Copy link

One other note: you might want to pick a different name! Storage here is getting interface-merged with the global Storage interface for the web storage API! (playground) That's not the cause of the issue you're seeing here, but it is likely to troll you in other ways.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment