#Enforce unique keys in static "list"

84 messages · Page 1 of 1 (latest)

nocturne mountain
#

Probably easiest for me to illustrate in code.

  type Register<T> = // no idea what this looks like but it's a list unique keys (possibly union or tuple?)

  Register<1111> // okay
  Register<2222> // okay
  Register<3333> // okay
  Register<1111> // WE HATE THIS. BAD BAD BAD.

I can do this sort of check during runtime, but it feels like it would be possibly with some typescript wizardry, either with unions or tuples. Am I crazy?

ashen pine
#

Not possible without changing your API somehow

#

TS isn't magic, if you want to check if T already been used before, then you need to keep a list of already used T in the type system to check against.

deep musk
#

it would be possible with some kind of builder pattern, but I would not recommend it

#

mainly because that is something that should be checked at runtime

#

and doing it at runtime is veryeasy, whereas doing it ac ompile time is quite hard and brings little to no additional value

#

note you'd need to do that check at runtime too anyway if you are writing a library, since not all your consumers might be using TypeScript

ashen pine
#

Yeah that's changing the API

#

And I assume they want to use register like a free function, and very likely in many different files.

deep musk
#

hum, maybe Symbol.for() is what OP is looking for

#

and it's corresponding type, readonly unique symbol

ashen pine
#

Isn't that runtime?

deep musk
#

yes, but wouldn't having a unique symbol at compile time also work? 🤔

#

but it really depends what OP is trying to achieve

#

if the goal is to avoid re-creating the same type several times, and force you to re-use the existing one, then branded types are also an option

ashen pine
#

Only if you don't expose the creation function for the branded type.

#

I don't see how unique symbol will help either

#

Well, I'm assuming Register<T> is supposed to be a function, so you need to preserve the T in the type system somehow, and the only way is via preserving the function return.

#

Which is why your builder pattern suggestion works since T can be preserved in the return (but even then there's no way of stopping consumer from ignoring the return and just make a new builder)

nocturne mountain
#

I was thinking a builder pattern as well, @deep musk . I'm enforcing unique error codes in our codebase. The goal is to allow developers to define new Error types with unique error codes but to check it statically so they don't have to wait for runtime to verify that something overlaps with an existing code. These error codes need to be referenced by users when contacting support so I'm not sure Symbols would be valid, unless they can be serialized on the client in a human readable form?

deep musk
#

just write them down somehwere whenever you're adding a new one ¯_(ツ)_/¯

ashen pine
#

There's not enough context to what you want to achieve, show some code and how you want your API to be used so we can give better suggestions.

nocturne mountain
#

It's vague on purpose, @ashen pine 🙃

I simply want to enforce unique error codes statically with minimal effort from the developer.

// OVERLY COMPLICATED
// This part can be ridiculously complex
type ApplicationErrorCode = number & {__brand: 'ApplicationErrorCode' }
 
abstract class ApplicationError extends Error {
  abstract code: ApplicationErrorCode
}
// END COMPLICATIONS

// TRIVIAL
// This part should be trivial
// Yay! Everyone's happy! Except the guy that
// wrote the code above (probably me).
class APIError extends ApplicationError {
  code: 1111
}

class InvalidInput extends ApplicationError {
  code: 2222
}

class EXAMPLE extends ApplicationError {
  code: 1111 // NO BUENO, BOOOO, BAD BAD BAD
}
#

I realize the last half would probably have to be a factory function, but that's okay as well.

weak swift
#

well, you could register them all in a shared interface

nocturne mountain
#

My existing code checks at runtime.

export type ApplicationErrorCode = number & { __brand: "ApplicationErrorCode" };

export abstract class ApplicationError extends Error {
    abstract code: ApplicationErrorCode;

    static #codes = new Set();
    static code(value: number) {
        // runtime check to ensure that the code is unique
        if (this.#codes.has(value))
            throw new Error(
                `ApplicationError ${value} already exists. Please use a new code.`
            );

        this.#codes.add(value);
        return value as ApplicationErrorCode;
    }
}


class EXAMPLE extends ApplicationError {
  code: ApplicationError.code(1111)
}

But I'm exploring the possibility of making it a static check. I'm just not creative enough with TS.

weak swift
#

it takes some extra work on your part, and if you forget you get no errors

#

but it'll prevent error code collision

nocturne mountain
#

Can that be statically done, @weak swift ?

ashen pine
#

How would that work?

#

What's stopping someone from making a new class but forgetting to register it?

nocturne mountain
#

In my runtime code? Because you have to use a branded ApplicationErrorCode and the only way to produce it is using the static method on the interface.

ashen pine
#

No, in type system.

#

Checking at runtime is trivial.

nocturne mountain
#

If you're extending the ApplicationError class, you have to use a branded code. If I could somehow use the static method to verify the uniqueness of the codes statically, it's a moot issue.

ashen pine
#

That's the thing, you have to preserve the information of "code 1111 is used" in the type system somehow

#

And there's no way to do that with the code you want to write which is just declaring a new class.

nocturne mountain
#

Right, and a builder pattern would require chaining, right?

ashen pine
#

Yes, because a builder pattern returns a new object which encodes the information of "the code 1111 is already used."

#

But then again, if you are not using the return, that information is once again lost.

#

So let's say most likely you will be defining each error class in their separate files, and each of them using a new builder will not help because none of them have that information of what other classes have used.

nocturne mountain
#

Classes are technically interfaces, right? And typescript is structural. Is there a way to define a property in such a way that it's readonly but be able to extend that on a different interface with another readonly prop?

ashen pine
#

Classes are not interfaces, but yes you can.

#
interface MyError {
    readonly code: string
}

interface MyFooError extends MyError {
    readonly code: 'foo'
}
#

Not sure how that would solve your problem though.

nocturne mountain
#

Just a thought exercise, but....

class ApplicationError {
  codes: {}
}

class A extends ApplicationError {
  codes: {
    readonly ['1111']: true
  }
}

class B extends ApplicationError {
  codes: {
    readonly ['2222']: true
  }
}

class OOPSY extends ApplicationError {
  codes: {
    readonly ['1111']: true // complains here
  }
}
ashen pine
#

That's not possible, OOPSY doesn't know about A.

nocturne mountain
#

Oh right, because it's not static.

ashen pine
#

Depending on what your errors are actually doing, if it's just a simple error message I'd go with something like:

export const { FooError, BarError } = defineErrors({
    1111: ['FooError', 'An error of Foo occurred'],
    2222: ['BarError', 'An error of Bar occurred'],
})

Type checking that would be trivial.

weak swift
#

if the only distinguishing factor is their code property, my idea of registering them wont work

#

because they type-system can't tell the various subclasses apart

#

example:

viral skyBOT
#
webstrand#0

Preview:```ts
interface AllTheErrors {}
type LookupErrorCode<T> = keyof {
[P in keyof AllTheErrors as (<>() => _ extends T
? 1
: 0) extends <
>() => _ extends AllTheErrors[P]
? 1
: 0
? P
: never]: P
}

abstract class ApplicationError<K> extends Error {
abstract code: LookupErrorCode<K>
...```

weak swift
#

but that relies on subclasses being distinct from one another

#

the a,b,c,d properties are the ones that make the subclasses distinct

#

alternative to all of this: you could write it up as a lint, perhaps.

#

especially if all of the error types are defined in the same file

nocturne mountain
#

The errors are colocated, @weak swift . These codes would need to be unique projectwide.

weak swift
#

in the example above you could use declare global {} around the AllTheErrors interface to make it project-wide

nocturne mountain
#

If I could find some way to do this programmatically, it does exactly what I want. Essentially maintaining a list of values globally (using an interface) and ensuring that they're unique using Symbols (the type of a key already defined on an interface can't be changed and Symbols, by definition, have to be unique).

Now if I could figure out how to do this with a factory/helper pattern of some sort....

interface Codes {}

const av = '1111'
const a = Symbol(av)
interface Codes {
    [av]: typeof a
}

const bv = '2222'
const b = Symbol('2222')
interface Codes {
    [b]: typeof b
}

const cv = '1111'
const c = Symbol(cv)
interface Codes {
    [cv]: typeof c // correctly throws an error
}
weak swift
#

I'd suggest that typescript itself doesn't have the solution that you're looking for. However, if you were to write a simple eslint plugin, you could check for that

#

since you'd be able to build and maintain the global list in eslint's memory

#

a typescript plugin might even work, I'm not sure what the capabilities are, though.

nocturne mountain
#

Linting is probably the way to go. Even if I managed to pull this off in Typescript, it's very much a monstrosity of a hack.

ashen pine
#

I would imagine you don't add new errors often, if there is a new duplicate it would either be caught during PR or tests, not worth setting up an entire linting rule or plugin for it.

solemn path
#

i wouldn't recommend actually doing this, buuuut:

viral skyBOT
#
const registerError: <A, B>(a: A, b: B extends Partial<A> ? never : B) => asserts a is A & B = (a, b) => {
  // ...
}

declare const _hax: unique symbol
const reservedCodes: { [_hax]?: string } = {}

const av = '1111'
const a = Symbol(av)
registerError(reservedCodes, { [av]: a })

const bv = '2222'
const b = Symbol('2222')
registerError(reservedCodes, { [b]: b })

const cv = '1111'
const c = Symbol(cv)
registerError(reservedCodes, { [cv]: c })
//                           ^^^^^^^^^^^
// Argument of type '{ 1111: symbol; }' is not assignable to parameter of type 'never'.
solemn path
#

i mostly agree with @ashen pine on what to actually do, but this was fun to write

ashen pine
solemn path
#

probably not (but i haven't tried it)

nocturne mountain
# solemn path i wouldn't recommend actually doing this, buuuut:

This is super interesting to me, @solemn path .

declare const _never: unique symbol;
const reservedCodes = {} as { [_never]: never };
type Combine<T extends PropertyKey, E> = {
    [K in T]: K;
} & E;

function registerError<A extends PropertyKey, B>(
    a: A extends keyof B ? never : A,
    b: B
): asserts b is Combine<A, B> {}

registerError(1111, reservedCodes);
registerError(2222, reservedCodes);
registerError(1111, reservedCodes);
//            ^^^^
// Argument of type 'number' is not assignable to parameter of type 'never'.

Simpler syntax though the error is more cryptic.

Essentially asserts forces typescript to update it's in memory version of the value [read type]. Like @ashen pine said, I'm curious if this works across multiple files. I imagine that any assertion using the same codes object (reservedCodes) would update the type, regardless of where it occurred?

ALSO, is it possible to remove the second argument from the function call? I realize asserts only works with function arguments but is it possible to do some more TS wizardry and make reservedCodes the default value (IE don't require the user to pass it)?

ashen pine
#

Pretty sure it doesn't work across files.

#

One way to improve it is instead use a registry class and do:

registry.add(1111)

And the Registry#add asserts this.

#

I was not able to get it to work when I tried it, it seems to just break if you use a generic.

nocturne mountain
#

I love when I go down a rabbit hole and start running into new TS Errors 😂

declare const _never: unique symbol;

class Registry<T extends { [_never]: never }> {
    #existing = new Set();

    add<K extends PropertyKey>(
        this: Registry<T>,
        key: K
    ): asserts this is Registry<Combine<K, T>> {
        if (this.#existing.has(key))
            throw new Error(`Key ${key.toString()} already exists.`);

        this.#existing.add(key);
    }
}

const registry = new Registry();
registry.add(1111);
// ^^^^^^^^^^^^^^^
// Assertions require every name in the call target to be declared with an explicit type annotation.
ashen pine
#

Yeah it just doesn't work.

#

For it to work, add cannot be generic, and you also need to annotate const register: Registry = ...

weak swift
#

I've improved my solution:

viral skyBOT
#
webstrand#0

Preview:```ts
export {}

declare global {
interface GlobalErrorCodeRegistry {}
}

abstract class ApplicationError<
T extends GlobalErrorCodeRegistry[keyof GlobalErrorCodeRegistry]

extends Error {
protected abstract isNominal?: never
abstract code: {
[P in keyof GlobalErrorCodeRegistry]: GlobalErrorCodeRegistry[P] extends T
? P
: never
}[keyof GlobalErrorCodeRegistry]
...```

weak swift
#

the only error that my solution can't handle, is when you extend from the wrong super class, i.e. type MyError extends ApplicationError<YourError>

#

the isNominal property forces the classes to be nominally compared rather than structurally

#

so theres no need to enforce uniqueness