I’m working on a TypeScript utility that allows registering events with a defined schema and emitting them while enforcing strict type checking for parameters.
The issue arises with optional parameters defined in the schema. I expect that when a key is marked optional, it should be possible to omit it entirely. However, TypeScript still raises an error when I omit the key during event emission.
Here is the minimal reproduction of my issue:
type ParameType<T> = {
regexSnippet: string
parse?: (value: string) => any
type: T
}
const ParameterTypes = {
"string": <ParameType<string>>{ regexSnippet: "\w+" },
"number": <ParameType<number>>{ regexSnippet: "\d+", parse: parseInt },
"string?": <ParameType<undefined | string>>{ regexSnippet: "\w*" },
"number?": <ParameType<undefined | number>>{ regexSnippet: "\d*", parse: parseInt },
} as const
type ParamsTypeMap = typeof ParameterTypes
type ParamsTypeKeys = keyof ParamsTypeMap
type ParamsSchema = { [name: string]: ParamsTypeKeys }
type ParamsSchemaToObject<T extends ParamsSchema> = { [K in keyof T]: ParamsTypeMap[T[K]]["type"] }
type Settings = {
parameters: ParamsSchema
}
class Test<T extends { [name: string]: Settings } = {}> {
protected events: T = {} as T
registerEvent<K extends string, V extends Settings>(
eventname: K,
settings: V
): asserts this is Test<T & { [keyname in K]: V }> {
// ...
}
emit<K extends keyof T>(
eventname: K,
parameters: ParamsSchemaToObject<T[K]["parameters"]>
): any {
// ...
}
}
const test: Test = new Test()
test.registerEvent("prmtst", { parameters: { a: "number", b: "number?", c: "string" } })
test.emit("prmtst", { a: 1, b: 2, c: "str" }) // OK
test.emit("prmtst", { a: 1, c: "str" }) // Error, but `b` is optional
In the example above, the parameter b
is marked as optional (number?
), but TypeScript still throws an error when I omit it entirely.
Question:
How can I modify the ParamsSchemaToObject
type so that optional keys like b
can be safely omitted when emitting events?
6
There are two separate things you’re trying to accomplish.
One is that whenever you have a property that accepts undefined
, you’d like that property to be optional. For that, we can write a utility type UndefToOptional<T>
that converts a type like {a: string | undefined, b: number}
into a type like {a?: string, b: number}
. The straightforward way to do that is to use the Partial utility type to make every property optional, and then intersect it with a key-remapped type that keeps just the non-undefined
-accepting properties as required:
type UndefToOptional<T> = Partial<T> &
{ [K in keyof T as undefined extends T[K] ? never : K]: T[K] }
type X = UndefToOptional<{ a: string | undefined, b: number }>;
// ^? type X = Partial<{ a: string | undefined; b: number; }> & { b: number; }
It might not be obvious that this is the type you want, but it works. A more complicated definition is:
type UndefToOptional<T> = { [K in keyof T]:
(x: undefined extends T[K] ? { [P in K]?: T[K] } : { [P in K]: T[K] }) => void } extends
Record<keyof T, (x: infer V) => void> ? { [K in keyof V]: V[K] } : never
type X = UndefToOptional<{ a: string | undefined, b: number }>;
// ^? type X = { a?: string | undefined; b: number; }
You can see that the output type is prettier. What I’m doing here is effectively the same as the Transform union type to intersection type approach, where we intersect a bunch of one-property objects, each one either partial or required. So for {a: string | undefined, b: number}
we end up with {a?: string} & {b: number}
and then combine those in a single mapped type with {[K in keyof V]: V[K]}
. I don’t know that it’s worth describing exactly how and why this works. If you want a simpler implementation, use the intersection above.
Now we can just modify ParamsSchemaToObject
so that it uses UndefToOptional
:
type ParamsSchemaToObject<T extends ParamsSchema> =
UndefToOptional<{ [K in keyof T]: ParamsTypeMap[T[K]]["type"] }>
The second thing is that you want the emit()
method to allow the second parameter to be optional if it accepts a weak-typed object. A weak object has all optional properties. TypeScript doesn’t make it easy to have a parameter be either optional or required via a switch. You can do it if you give the function a tuple-typed rest parameter which can then be either a regular (required) tuple or one with optional elements. So let’s define WeakToOptionalTuple<T>
:
type WeakToOptionalTuple<T> = {} extends T ? [T?] : [T]
A weak object is one to which the empty object {}
is assignable. If T
is weak, then WeakOptionalTuple
is a single-element tuple with an optional T
element; otherwise it’s a single-element tuple with a required T
element. And then we can write emit()
as
emit<K extends keyof T>(
eventname: K, ...[parameters]: WeakToOptionalTuple<
ParamsSchemaToObject<T[K]["parameters"]>
>): any {
// ...
}
where the last parameter is a destructured rest parameter named parameters
, and its type is that optional-or-required tuple.
Let’s test it out:
test.registerEvent("str", { parameters: { a: "string?" } });
test.registerEvent("prmtst", { parameters: { a: "number", b: "number?", c: "string" } });
test.emit("prmtst", { a: 1, c: "str" }); // okay
test.emit("str", {}); // okay
test.emit("str"); // okay
test.emit("prmtst"); // error
If you look at the call signature for test.emit("prmtst", ⋯)
, it is (eventname: "prmtst", parameters: { a: number; b?: number; c: string;}): any
, whereas the call signature for test.emit("str", ⋯)
is (eventname: "str", parameters?: { a?: string }): any
, so you can see that the properties and parameters are optional in exactly the cases you care about.
Playground link to code