Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 115 additions & 6 deletions packages/schema-generator/src/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,114 @@ export function safeGetPropertyType(
checker: ts.TypeChecker,
fallbackNode?: ts.TypeNode,
): ts.Type {
// Prefer declared type node when available
const decl = prop.valueDeclaration;
const isOptional = (prop.flags & ts.SymbolFlags.Optional) !== 0;

// Get type from the resolved parent type (for generic instantiation and mapped types)
// We'll use this to validate/override the declaration-based type if needed
let typeFromParent: ts.Type | undefined;
try {
const propName = prop.getName();
const propSymbol = checker.getPropertyOfType(parentType, propName);
if (propSymbol) {
typeFromParent = checker.getTypeOfSymbol(propSymbol);
const typeStr = typeFromParent
? checker.typeToString(typeFromParent)
: undefined;
// If we got 'any', treat it as if we didn't get a type
if (typeStr === "any") {
typeFromParent = undefined;
}
}
} catch {
// Type resolution can fail for some edge cases
}

// Try to get type from declaration
let typeFromDecl: ts.Type | undefined;
if (decl && ts.isPropertySignature(decl) && decl.type) {
const typeFromNode = safeGetTypeFromTypeNode(
typeFromDecl = safeGetTypeFromTypeNode(
checker,
decl.type,
"property signature",
);
if (typeFromNode) return typeFromNode;
}

// If we have both, and they differ, prefer parent (handles generic instantiation)
// Example: Box<number> where property is declared as `value: T` but should resolve to `number`
if (typeFromParent && typeFromDecl) {
const parentStr = checker.typeToString(typeFromParent);
const declStr = checker.typeToString(typeFromDecl);

if (parentStr !== declStr) {
// For optional properties, the parent type may include "| undefined" which we don't want
// The optionality is tracked separately in the schema via the required array
// Check if parent is a union that contains undefined, and if removing it gives us the decl type
if (isOptional && typeFromParent.isUnion()) {
const parentUnion = typeFromParent as ts.UnionType;
const hasUndefined = parentUnion.types.some((t) =>
!!(t.flags & ts.TypeFlags.Undefined)
);

if (hasUndefined) {
const nonUndefinedTypes = parentUnion.types.filter(
(t) => !(t.flags & ts.TypeFlags.Undefined),
);

// Compare the non-undefined part with the declaration type
// Handle both single types and remaining unions
if (nonUndefinedTypes.length === 1 && nonUndefinedTypes[0]) {
const withoutUndefined = checker.typeToString(nonUndefinedTypes[0]);
if (withoutUndefined === declStr) {
// Parent only differs by the added | undefined, use decl
return typeFromDecl;
}
} else if (nonUndefinedTypes.length > 1) {
// Multiple non-undefined types - check if decl is also a union with the same types
// For now, just check string equality which should work for most cases
const withoutUndefinedStr = nonUndefinedTypes.map((t) =>
checker.typeToString(t)
).join(" | ");
if (
withoutUndefinedStr === declStr || declStr === withoutUndefinedStr
) {
return typeFromDecl;
}
}
}
}

// Types differ - parent has the instantiated/resolved version (e.g., T -> number)
return typeFromParent;
}
}

// If only parent type available (e.g., mapped types with no declaration), use it
if (typeFromParent && !typeFromDecl) {
// For optional properties from mapped types (like Partial<T>), the parent type
// includes "| undefined", but we want just the base type since optionality
// is tracked separately in the required array
if (isOptional && typeFromParent.isUnion()) {
const unionType = typeFromParent as ts.UnionType;
const nonUndefinedTypes = unionType.types.filter(
(t) => !(t.flags & ts.TypeFlags.Undefined),
);
if (nonUndefinedTypes.length === 1 && nonUndefinedTypes[0]) {
return nonUndefinedTypes[0];
}
// If multiple non-undefined types, just use parent as-is
// The schema generator will handle the union properly
}

return typeFromParent;
}

// Otherwise use declaration type
if (typeFromDecl) {
return typeFromDecl;
}

// Fallback to provided node
if (fallbackNode) {
const typeFromFallback = safeGetTypeFromTypeNode(
checker,
Expand All @@ -92,7 +189,7 @@ export function safeGetPropertyType(
if (typeFromFallback) return typeFromFallback;
}

// Last resort: use symbol location
// Try symbol location as last resort before giving up
if (decl) {
const typeFromSymbol = safeGetTypeOfSymbolAtLocation(
checker,
Expand All @@ -103,7 +200,7 @@ export function safeGetPropertyType(
if (typeFromSymbol) return typeFromSymbol;
}

// If all else fails, return any
// Absolute last resort - return 'any' type
return checker.getAnyType();
}

Expand Down Expand Up @@ -203,7 +300,6 @@ export function getNativeTypeSchema(

return resolve(type);
}

/**
* Return a public/stable named key for a type if and only if it has a useful
* symbol name. Filters out anonymous ("__type") and wrapper/container names
Expand Down Expand Up @@ -280,6 +376,19 @@ export function getNamedTypeKey(
return undefined;
}
if (name && NATIVE_TYPE_NAMES.has(name)) return undefined;

// Don't hoist generic type instantiations (Record<K,V>, Partial<T>, Box<T>, etc.)
// These have aliasTypeArguments, meaning they're a generic type applied to specific type arguments
// The name "Record" or "Box" is meaningless without the type parameters - what matters is the
// resolved/instantiated type structure
const typeWithAlias = type as TypeWithInternals;
if (
typeWithAlias.aliasTypeArguments &&
typeWithAlias.aliasTypeArguments.length > 0
) {
return undefined;
}

return name;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
{
"type": "object",
"properties": {
"stringConfig": {
"type": "object",
"properties": {
"theme": {
"type": "string"
},
"language": {
"type": "string"
},
"timezone": {
"type": "string"
}
},
"required": [
"theme",
"language",
"timezone"
]
},
"numberLimits": {
"type": "object",
"properties": {
"cpu": {
"type": "number"
},
"memory": {
"type": "number"
},
"disk": {
"type": "number"
}
},
"required": [
"cpu",
"memory",
"disk"
]
},
"booleanFeatures": {
"type": "object",
"properties": {
"auth": {
"type": "boolean"
},
"api": {
"type": "boolean"
},
"ui": {
"type": "boolean"
}
},
"required": [
"auth",
"api",
"ui"
]
},
"objectHandlers": {
"type": "object",
"properties": {
"create": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
},
"update": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
},
"delete": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
}
},
"required": [
"create",
"update",
"delete"
]
}
},
"required": [
"stringConfig",
"numberLimits",
"booleanFeatures",
"objectHandlers"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
interface SchemaRoot {
stringConfig: {
[P in "theme" | "language" | "timezone"]: string;
};
numberLimits: {
[K in "cpu" | "memory" | "disk"]: number;
};
booleanFeatures: {
[F in "auth" | "api" | "ui"]: boolean;
};
objectHandlers: {
[H in "create" | "update" | "delete"]: { enabled: boolean };
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"type": "object",
"properties": {
"settings": {
"type": "object",
"properties": {
"theme": {
"type": "string"
},
"language": {
"type": "string"
},
"timezone": {
"type": "string"
}
},
"required": [
"theme",
"language",
"timezone"
]
},
"limits": {
"type": "object",
"properties": {
"cpu": {
"type": "number"
},
"memory": {
"type": "number"
},
"disk": {
"type": "number"
}
},
"required": [
"cpu",
"memory",
"disk"
]
},
"features": {
"type": "object",
"properties": {
"auth": {
"type": "boolean"
},
"api": {
"type": "boolean"
},
"ui": {
"type": "boolean"
}
},
"required": [
"auth",
"api",
"ui"
]
}
},
"required": [
Copy link
Contributor

@ubik2 ubik2 Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if we should have "additionalProperties": false on these. In the TypeScript world, we don't let you assign an object with extra properties to these.

Edit: this isn't meant to be something that should be changed -- just not sure what the best behavior is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was a cool question that caused me to learn some things about typescript, thank you :)

here's an amalgamation of some inputs from claude that make sense to me as a set of takeaways:

Arguments for "additionalProperties": false:

  • Stricter validation matches TypeScript's intent
  • Catches accidental extra properties (typos, outdated fields)
  • More defensive/explicit about the contract

Arguments against (current behavior):

  • TypeScript's excess property checking only applies to object literals in direct assignments
 type Settings = Record<"theme" | "language", string>;
 const valid: Settings = { theme: "dark", language: "en" };           // ✓
 const extra: Settings = { theme: "dark", language: "en", foo: "x" }; // ✗ TypeScript error
 // ...but this is valid TypeScript:
 const obj = { theme: "dark", language: "en", foo: "x" };
 const settings: Settings = obj; // ✓ No error! TypeScript's structural typing
  • JSON Schema is often used for runtime validation where data comes from external sources
  • Being more permissive at runtime matches TypeScript's structural nature
  • The schema generator has been following a pattern of only adding additionalProperties: false when TypeScript explicitly forbids extra properties (not just excess
    property checking)

What do you think the validation use case is here? Are these schemas primarily for:

  • Type validation at API boundaries (stricter might be better)
  • Internal type checking (current behavior is fine)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merging this for now but happy to keep chatting about this topic and can easily make whatever changes we decide on in a follow-up!

"settings",
"limits",
"features"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface SchemaRoot {
settings: Record<"theme" | "language" | "timezone", string>;
limits: Record<"cpu" | "memory" | "disk", number>;
features: Record<"auth" | "api" | "ui", boolean>;
}
Loading