1

I'm trying to create a type to represent a list (hash) of HTTP headers. So this type should be a hash containing only key / string pairs:

type TStringStringHash = {
    [key: string]: string
}

But this allows an empty object of type TStringStringHash to be instantiated:

const foo: TStringStringHash = {}

Which doesn't really make sense in my implementation. I want an object of type TStringStringHash to hold at least one indexed property:

const foo: TStringStringHash = { foo: "bar" }

Every answer I've found so far addresses how to force either one of two non-indexed optional properties to be assigned, no answer I could find seems to address this particular issue.

I apologize in advance if the answer to this happens to be trivial, but so far I haven't been able to figure this out on my own.

Thanks!

kos
  • 510
  • 3
  • 18
  • Not to say this isn't possible to solve, I'm sure some algebraic type could be contrived to satisfy this constraint, but the fact that an empty object is a special case you'd like to prevent in some interface type that otherwise just requires any number of arbitrary key-value string pairs is a bit of a code smell. To me, this is an indication that this may be a poor design choice and that there may be another better design for whatever your use-case may be. – Patrick Roberts Aug 01 '22 at 05:26
  • @PatrickRoberts Well I *could* have an empty object be acceptable (instances of `TStringStringHash` will be passed to a JS `Request`, which allows empty object to be passed as well), however this is more in order to prevent me from making silly mistakes; if I'm passing an empty set of headers, for some reason, I'd like to be notified. Since having an empty set of headers doesn't currently make sense in my app, it sounds ok to me to prevent me from instantiating something like that inadvertedly. – kos Aug 01 '22 at 05:36

2 Answers2

1

Build the hash using a function, where you can constrain the argument according to your needs:

TS Playground

type StringEntry = [key: string, value: string];
type HashEntries = [StringEntry, ...readonly StringEntry[]];

function createHash (entries: HashEntries): Record<string, string> {
  return Object.fromEntries(entries);
}

const hash1 = createHash([
  ['name', 'value'],
]);

console.log(hash1); // { name: "value" }

const hash2 = createHash([
  ['name', 'value'],
  ['name2', 'value2'],
]);

console.log(hash2); // { name: "value", name2: "value2" }

createHash(); /*
~~~~~~~~~~~~
Expected 1 arguments, but got 0.(2554) */

createHash([]); /*
           ~~
Argument of type '[]' is not assignable to parameter of type '[StringEntry, ...StringEntry[]]'.
  Source has 0 element(s) but target requires 1.(2345) */

createHash([
  ['name'], /*
  ~~~~~~~~
Type '[string]' is not assignable to type 'StringEntry'.
  Source has 1 element(s) but target requires 2.(2322) */
]);

createHash([
  ['name', 'value', 'extra'], /*
  ~~~~~~~~~~~~~~~~~~~~~~~~~~
Type '[string, string, string]' is not assignable to type 'StringEntry'.
  Source has 3 element(s) but target allows only 2.(2322) */
]);

jsejcksn
  • 27,667
  • 4
  • 38
  • 62
  • Thanks, +1. Actually if one has to go down the "prevent the thing using a helper function" route, adding a helper function to pop elements from the hash as well might be even better than a TS-only solution, as it could prevent the thing from becoming empty at run-time as well. Hopefully there will be a TS-only solution though, I'd like to be able to instantiate the thing directly (`const foo: TStringStringHash = { foo: "bar" }`) – kos Aug 01 '22 at 06:18
  • @kos it is possible to make it directly instantiated only if you know all properties upfront, otherwise you need an extra function for type inference – captain-yossarian from Ukraine Aug 01 '22 at 07:06
  • @kos There are multiple functional techniques for validating/constraining types in TS, and yes — like you said — there are also benefits to combining runtime validation into a functional approach, but if you're looking for something that gets completely erased at compile time, I'm not sure whether it'll be possible. – jsejcksn Aug 01 '22 at 07:06
1

It is possible to validate empty objects:

type OnlyLiteral<Hash extends Record<string, string>> =
    Record<string, string> extends Hash ? never : Hash

type NotEmpty<Hash extends Record<string, string>> =
    keyof Hash extends never ? never : Hash

const hash = <
    Hash extends Record<string, string>
>(obj: OnlyLiteral<NotEmpty<Hash>>) => {

}

const empty = {}
const emptyTyped: Record<string, string> = {}


hash({}) // expected error
hash(empty) // expected error
hash(emptyTyped) //expected error

hash({ a: 'a' }) // ok

Playground

You just need to negate all disallowed values and to use never instead of them.

Above solution should be thoroughly tested. Also it works only with literal arguments

If you are interested in this approach you can check my article

  • Thanks, I don't fully understand how this works yet (TS noob), but I gather you're "merging" the two contraints toghether; I'll look up what all those definitions mean by myself, however I was just wondering, is there a way to use this with "standard" property assignment? Like, `const hash = {"foo":"something"}`? – kos Aug 01 '22 at 19:39
  • It is possible only if you have a set of allowed keys. – captain-yossarian from Ukraine Aug 01 '22 at 20:33
  • @kos see [this](https://stackoverflow.com/questions/69246630/how-to-express-at-least-one-of-the-exisiting-properties-and-no-additional-prope/69246782#69246782) answer, it should help. – captain-yossarian from Ukraine Aug 02 '22 at 06:34
  • 1
    Actually I think I'll go with the answer you linked unless someone comes up with a TS only solution that doesn't involve defining an acceptable set of keys beforehand. I think I prefer to commit to keeping a list of acceptable headers than to use a helper function just for this one specific case... Thanks again! – kos Aug 02 '22 at 18:20