Home > Mobile >  Disallow non-enum keys for object in generic function
Disallow non-enum keys for object in generic function

Time:12-07

Consider following code: screenshot

CodePudding user response:

Why did you write f as a generic function which accepts any type that extends Smth? Just write it as a regular function or remove the extends constraint:

const enum SomeEnum {
  a = "a",
  b = "b",
}

type Smth = {
  [key: string]: {
    args?: { [key: string]: () => string }
  } & {
    [key in SomeEnum]: string
  }
}

function f(obj: Smth): { [key in keyof Smth]: { [key in SomeEnum]: () => string } } {
  return null!
}

const t = f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    a: "zzz",
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // Should be an error
  }
})

TypeScript playground

CodePudding user response:

To some extent it is impossible to completely prevent extra keys in object types. TypeScript's entire type system is built around structural typing where object types only care about known properties and don't prohibit unknown properties. Object types in TypeScript are considered open and extendible, and not closed or "exact" (see microsoft/TypeScript#12936 for the feature request to support exact types).

That means no matter how you write f()'s call signature, someone can end up passing unknown properties in without doing anything unsound:

const val = { a: "ok", b: "fine", oooops: "???" };
const smth: { a: string, b: string } = val; // <-- this is accepted
f({ prop1: smth }) // and so is this

The assignment of val to smth is acceptable because val has all the properties in {a: string, b: string}. The fact that there is an extra oooops property is not an error.

The sort of checking that produces errors here:

const notAllowed: { a: string, b: string } =
  { a: "ok", b: "fine", oooops: "???" }; // error!
// -------------------> ~~~~~~~~~~~~~
// Object literal may only specify known properties

is called excess property checking and only triggers in places where the compiler sees that the knowledge of extra properties will be thrown away by the compiler. In notAllowed, you are immediately discarding knowledge of the oooops property and therefore the compiler thinks it might be a mistake to have it there. This is more of a linter rule than a type safety rule.

But in const smth: {a: string, b: string } = val, the val object still exists separately and the compiler knows about oooops. And in your original code, the S generic type parameter will be inferred as something having oooopsin it. The extra property doesn't get discarded, so it's not seen as an error.

So one suggestion here is to just accept excess properties, make sure the implementation of f() doesn't do bad things if it gets them, and don't worry about it.


If you really want to prohibit extra keys, you can change the call signature of f() to do so. My first approach here works for simple cases where all you care about tracking are the keys to the obj parameter:

type SmthValue =
  { args?: { [K: string]: () => string } } &
  { [K in SomeEnum]: string };

declare function f<K extends PropertyKey>(
  obj: { [P in K]: SmthValue }
): { [P in K]: { [P in SomeEnum]: () => string } };

Instead of tracking the entire S type parameter, we only track its keys K, and for each value we just use SmthValue (the same as your Smth[string]). Since SmthValue is a specific type, if you pass in an object literal with extra keys, the K type will not know about them, and thus you'd be discarding the information. Which triggers excess property checking warnings:

f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    a: "zzz",
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // error!
    // ~~~~~~~~~~ 
    // Object literal may only specify known properties
  }
})

But you have a return type for f() which cares about the particulars of the obj property values as well as the keys. So this isn't going to work for you.


In the general case where we need to keep S, we can write a type function which simulates exact types by finding extra properties and mapping their value types to never. See this comment on microsoft/TypeScript#12936 for more information. Here's how I'd write it:

declare function f<S extends Record<keyof S, SmthValue>>(obj: ProhibitExtraKeys<S>): {
  [K in keyof S]: { [P in SomeEnum]: S[K] extends { args: any } ? () => string : string } }

When you call f(something), the compiler will infer S as typeof something, and then check it against ProhibitExtraKeys<S>. If S is assignable to ProhibitExtraKeys<S> then everything is fine. If S is not assignable to ProhibitExtraKeys<S> then you'll get a compiler warning.

So this is ProhibitExtraKeys<S>:

type ProhibitExtraKeys<S> = {
  [K in keyof S]: {
    [P in keyof S[K]]: P extends (keyof SmthValue) | `${keyof SmthValue}` ? S[K][P] : never
  }
};

It walks through each property key K of S (which is allowed to be anything) and then for each subproperty key P of S[K], it maps the property value to either itself, or never, depending on whether that key P is expected or not.

The expected keys are (keyof SmthValue) | `${keyof SmthValue}`. If you didn't have an enum and were using "a" | "b" instead, I'd just use keyof SmthValue, which would evaluate to "args" | "a" | "b" and we'd be done. But keyof SmthValue is "args" | SomeEnum. And unfortunately, enum values are a bit weird. This is an error:

const x: SomeEnum = "a" // error!
// Type '"a"' is not assignable to type 'SomeEnum'

And so unless you want to prohibit P from being "a" or "b", you need to get the string values of SomeEnum. Which you can do with template literal types:

type AcceptableKeys = (keyof SmthValue) | `${keyof SmthValue}`
// type AcceptableKeys = SomeEnum | "args" | "a" | "b"

And now we will accept "args" or "a" | "b" or even SomeEnum. Let's see it work:

const t = f({
  prop1: {
    a: "abc",
    b: "def",
  },
  prop2: {
    args: {},
    [SomeEnum.a]: "zzz", // <-- okay too
    b: "xxx",
  },
  prop3: {
    a: "ok",
    b: "fine",
    oooops: "???", // error!
    //~~~~
    //Type 'string' is not assignable to type 'never'
  }
})

And there you go. The input and output are what you want, I think.

Playground link to code

  • Related