1

Consider following code: playground

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

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

function f<S extends Smth>(obj: S): { [key in keyof S]: { [key2 in SomeEnum]: S[key] extends { args: any } ? () => string : 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
  }
})

It compiles fine and gives to variable t type

const t: {
    prop1: {
        a: string;
        b: string;
    };
    prop2: {
        a: () => string;
        b: () => string;
    };
    prop3: {
        a: string;
        b: string;
    };
}

The only problem is that the code compiled with this line in prop3:

oooops: "???", // Should be an error

I want passing of non-enum value except args as a key to be an error.

And when this line is removed the code should work the same way as it now does.

How can I make it?

If possible, I'd like error in case of missing or extra field be pointing to either that field, or all neibour fields, or parent field, but not to the whole object.

I've tried to change type to

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

but it breaks normal cases.

Things like [key in Exclude<string, `${Lang}`>] don't work either.

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

Because I need correct return type:

screenshot

10
  • 2
    Does this approach work for you? If so I can write up an answer; if not, please elaborate on the unmet use cases. Commented Dec 6, 2021 at 19:48
  • @jcalz, I'll upvote your answer anyway, but actually this version doesn't solve the real case as there I need function f<S extends Smth>(obj: S): { [key in keyof S]: { [key2 in SomeEnum]: S[key] extends { args: any } ? () => string : string } } {. At least, it doesn't directly, possibly I need to play around & there. Commented Dec 6, 2021 at 20:00
  • You could modify the code in the question so that your actual use case is represented more accurately (with that return type) and I could try again? Or do you want to leave it as-is and get an answer which works for the question but maybe not for your use case? It's up to you. Commented Dec 6, 2021 at 20:05
  • @jcalz, ok, I'll update. Commented Dec 6, 2021 at 20:06
  • 1
    What do you think of this approach then? It's a bit more complicated. (Maybe you should remove the screenshot? They're not generally recommended anyway) Commented Dec 6, 2021 at 20:16

2 Answers 2

1

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

Sign up to request clarification or add additional context in comments.

1 Comment

Great, thanks! Interesting moment: if you replace P extends (keyof SmthValue) | `${keyof SmthValue}` by P extends `${keyof SmthValue}` then it will be working still, but in case of skipping one of the enum properties, all the fields will be marked as invalid.
0

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

1 Comment

Because I want to have correct return type: i.sstatic.net/hxOg6.png.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.