Reputation: 273
Here's the type I come up with so far
type Schema<T extends {}> = {
[K in keyof T]: T[K] extends {} ? Schema<T[K]> : JsType
}
type JsType = "boolean" | "string" | "function" | "number" | "symbol" | "undefined"
it doesn't work. Expected behaviour - kind
, amount
and isHalfed
should be of type JsType
(because they don't extend {}
I assumed). Instead they act weirdly and isHalfed
becomes Schema<boolean>
but kind
and amount
become string
and number
respectively.
interface Cup {
content: {
kind: string
amount: number
}
isHalfed: boolean
}
const cupSchema: Schema<Cup> = {
content: {
kind: "string"
//Error: Type 'string' is not assignable to type 'number'.
amount: "number"
}
// Error: Type 'string' is not assignable to type 'Schema<boolean>'.
isHalfed: "boolean"
}
Even if it would work - it would not be perfect. In ideal world property kind
would have string literal type "string"
, amount
- "number"
and isHalfed
- "boolean"
(all string literal types). This level of type precision is desired but not necessary.
My implementation seems to make typescript think that all shallow nested properties extend {}
- so they have type of Schema
, but deeply nested properties straight up ignore this behaviour (despite being wrapped in Schema
type)
Is there a way to fix this issue? And if possible - is there a way to make it work even better (like I described above) ?
Upvotes: 1
Views: 180
Reputation: 329388
The {}
type does not exclude primitives like string
or number
. The only values not assignable to the empty type {}
are null
and undefined
.
It may be confusing, but in TypeScript, "object" types surrounded by curly braces (such as interfaces) do not automatically prohibit primitives. If a type can be indexed into like an object (e.g., ("hello").toUpperCase()
or (123).toFixed(2)
), then it can be assignable to an object type. Since {}
does not have any required members, then everything except for null
and undefined
(which throw errors if you index into them) will be assignable to it. See this FAQ entry for more information.
It's possible you want to use the object
type instead, which was specifically introduced to match only non-primitive objects. If I make that change, your code compiles:
type Schema<T extends {}> = {
[K in keyof T]: T[K] extends object ? Schema<T[K]> : JsType
}
const cupSchema: Schema<Cup> = {
content: {
kind: "string",
amount: "number"
},
isHalfed: "boolean"
} // okay
Although, as you said, it's still not ideal, since it looks like
type SchemaCup = {
content: {
kind: JsType;
amount: JsType;
};
isHalfed: JsType;
}
Perhaps the "ideal" version would look like this:
type Schema<T> =
T extends boolean ? "boolean" :
T extends string ? "string" :
T extends Function ? "function" :
T extends number ? "number" :
T extends bigint ? "bigint" :
T extends symbol ? "symbol" :
T extends undefined ? "undefined" :
{
[K in keyof T]: Schema<T[K]>
};
which uses a chain of conditional types to pick out the exact string literal type for each primitive, and then falls back to the recursive object traversal if it's not one of those primitive types.
Let's see what we get for Schema<Cup>
now:
/* const cupSchema: {
content: {
kind: "string";
amount: "number";
};
isHalfed: "boolean";
} */
Looks good!
Upvotes: 1