Skip to content
31 changes: 31 additions & 0 deletions src/schema/getSignatureSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ export const getSignatureSchema = (
// Resolve the signature's return type and build its schema. The return type
// describes the value the function produces, so it carries no input
// suggestions.
//
// The *declared* return type is deliberately not threaded through here: its
// type parameters still carry their constraints (e.g. a REST trigger's
// `<T extends TYPE>` payload), and recovering a custom input from such a
// constraint would brand the return as a type picker. A return describes a
// produced value, never a slot the user fills, so a custom input like TYPE
// can never be the right answer for it — only the concrete instantiated type
// is resolved, which renders the shape the argument actually bound to.
const returnType = extractReturnType(checker, node, funktion)
const returnSchema: Schema = returnType
? getSchema(
Expand Down Expand Up @@ -678,6 +686,29 @@ const buildValueDrivenObjectSchema = (
: undefined
const isDataKind = funcSchema?.input === "data"

// Value-driven expansion only applies to a *structural* slot: a declared
// `data` object, whose properties and nested cardinality mirror the entered
// value, or a generic slot, which constrains nothing and lets the value drive
// the shape. Any other declared input is dedicated — the function side has
// already decided what the parameter is, and an entered value never downgrades
// it (the same rule mergeSchemas and buildValueDrivenItem follow).
//
// This matters for every data type that is structurally an object but renders
// as its own input (COLOR, FILE, and a `TYPE` / `<T extends TYPE>` picker):
// expanding it here would turn it back into the very `data` shape its input
// replaces. The declared schema is kept as-is — only the rendered `type` takes
// the entered value's concrete shape, mirroring how the instantiated return
// payload renders (see getSchema's custom-input handling).
if (funcSchema && !isDataKind && funcSchema.input !== "generic") {
return {
...funcSchema,
type: checker.typeToString(
checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr)),
),
...(suggestions?.length ? {suggestions} : {}),
} as Schema
}

const properties: Record<string, Schema | Schema[]> = {}
const required: string[] = []

Expand Down
156 changes: 138 additions & 18 deletions src/util/schema.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {getSubFlows} from "./subflows.util";
*/
export const CUSTOM_INPUT_IDENTIFIERS = {
DATE: "date",
TYPE: "type",
} as const satisfies Record<string, string>;

/** A data type identifier that maps to a custom input. */
Expand All @@ -40,6 +41,27 @@ export const isCustomInputIdentifier = (
): identifier is CustomInputIdentifier =>
identifier != null && identifier in CUSTOM_INPUT_IDENTIFIERS;

/** The set of input kinds produced by {@link CUSTOM_INPUT_IDENTIFIERS} (e.g. "date", "type"). */
const CUSTOM_INPUT_KINDS: ReadonlySet<string> = new Set(
Object.values(CUSTOM_INPUT_IDENTIFIERS),
);

/**
* Returns true if the given input kind is one produced by a custom-input data
* type (e.g. "date", "type"). Used by {@link mergeSchemas} to let a supplied
* value narrow the rendered `type` of such an input while the input kind itself
* is kept.
*
* Note this covers the registry-driven kinds only, so it is not the test for
* "is this input dedicated rather than structural" — COLOR is detected by shape
* (see isColorType) and never appears here. Code that must not expand a
* dedicated input back into the shape it was built from checks for the
* structural kinds it does expand ("data", "generic") instead.
*/
export const isCustomInputKind = (
input: string | undefined,
): boolean => input != null && CUSTOM_INPUT_KINDS.has(input);

/**
* Base interface for all input types.
* Provides common properties for suggestions and input metadata.
Expand Down Expand Up @@ -212,15 +234,13 @@ export interface ListInput extends Input {
}

/**
* Represents a complex type input with properties and required fields.
* Similar to DataInput but used for type definitions.
* Represents a type input.
* Emitted for the TYPE data type so the UI can render a dedicated type picker
* instead of the plain input its underlying type (`any`) would otherwise
* produce. Like {@link DateInput}, it carries no additional properties.
*/
export interface TypeInput extends Input {
input?: "type";
/** Record mapping property names to their schemas */
properties?: Record<string, Schema | Schema[]>;
/** Array of required property names */
required?: string[];
}

/**
Expand Down Expand Up @@ -288,6 +308,11 @@ export const getSchema = (
suggestionType?: ts.Type,
visited: Set<ts.Type> = new Set(),
recursionCache: Map<ts.Type, boolean> = new Map(),
// The type this schema node is declared as, tracked alongside the concrete
// `parameterType` when they differ because of generic instantiation. Only the
// declared type still carries a type parameter's constraint (e.g. a REST
// trigger's `<T extends TYPE>` payload), which the instantiated type has lost.
declaredType?: ts.Type,
): Schema => {

if ((parameterType.flags & ts.TypeFlags.TypeParameter) !== 0) {
Expand All @@ -301,19 +326,23 @@ export const getSchema = (

// The raw TypeScript type as a string, carried on every schema node so the
// consumer knows the concrete type each input was derived from. Custom-input
// data types (e.g. DATE) are branded as `<primitive> & {}` so their alias
// survives detection (see getSharedTypeDeclarations); for those, stringify the
// unbranded base member so the rendered type stays clean ("number", not
// "number & {}"). Every other type drops its top-level alias so the concrete
// structure is rendered rather than the wrapping alias name.
const type = checker.typeToString(
isCustomInputIdentifier(parameterType.aliasSymbol?.getName()) &&
parameterType.isIntersection()
? parameterType.types.find(
// data types are branded so their alias survives detection (see
// getSharedTypeDeclarations): primitive-based ones (e.g. DATE) as
// `<primitive> & {}`, from which we stringify the unbranded base member so the
// rendered type stays clean ("number", not "number & {}"); the `any`-based TYPE
// as the empty object `{}`, rendered as the neutral "object". Every other type
// drops its top-level alias so the concrete structure is rendered rather than
// the wrapping alias name.
const isCustom = isCustomInputIdentifier(parameterType.aliasSymbol?.getName());
const type = isCustom && parameterType.isIntersection()
? checker.typeToString(
parameterType.types.find(
(t) => (t.flags & ts.TypeFlags.Object) === 0
) ?? parameterType
: {...parameterType, aliasSymbol: undefined}
);
)
: isCustom
? "object"
: checker.typeToString({...parameterType, aliasSymbol: undefined});

// Suggestions are filtered by what the surrounding function accepts, not by
// the narrower type a current value happens to narrow the node-side to.
Expand Down Expand Up @@ -346,6 +375,18 @@ export const getSchema = (
],
} : {};

// A slot whose *declared* type is a type parameter constrained by a
// custom-input data type (e.g. a REST trigger's `<T extends TYPE>` payload,
// instantiated to a concrete argument) surfaces that custom input. The
// instantiated `type` string is kept so the concrete shape the value bound to
// stays visible (e.g. `{input: "type", type: "number"}`).
if (declaredType) {
const constraintInput = getCustomInputFromConstraint(checker, declaredType);
if (constraintInput) {
return {input: constraintInput, type, ...combinedSuggestions};
}
}

// Strip undefined and null from unions (e.g. string | undefined | null → string).
// Suggestions are collected above from the original type (preserving aliasSymbol literals),
// the base schema is determined from the stripped type, then both are merged.
Expand Down Expand Up @@ -528,9 +569,18 @@ export const getSchema = (
)
: [propertyType];

// The matching property on the declared type (when tracked), so a
// property whose declared type is a custom-input-constrained type
// parameter is recognised even after generic instantiation erased the
// constraint from the concrete `propertyType` (see getSchema's
// `declaredType` handling).
const declaredPropertyType = declaredType
? getDeclaredPropertyType(checker, declaredType, property.name)
: undefined;

// Recursively generate schemas for property types
const propertySchemas = propertyTypes.map((type) =>
getSchema(checker, node, type, functionDeclarations, functions, suggestions, undefined, visited, recursionCache)
getSchema(checker, node, type, functionDeclarations, functions, suggestions, undefined, visited, recursionCache, declaredPropertyType)
);

properties[property.name] =
Expand Down Expand Up @@ -797,6 +847,22 @@ export const mergeSchemas = (
};
}

// A custom-input data type (e.g. TYPE) keeps its dedicated input, but a
// supplied primitive value narrows the rendered `type` from the declared
// bound (TYPE's wide "object") to the value's concrete base type — so `42`
// against a `<T extends TYPE>` (or plain `TYPE`) slot renders
// {input:"type", type:"number"}, mirroring the instantiated return payload.
// Without a value the node side falls back to the function type, so the bound
// is preserved. Concrete-bound custom inputs (DATE = number) are unaffected:
// their node-side type already equals the bound.
if (isCustomInputKind(functionSchema.input as string | undefined)) {
return {
...functionSchema,
...(nodeSchema.type !== undefined ? {type: nodeSchema.type} : {}),
...(suggestions ? {suggestions} : {}),
};
}

return {
...functionSchema,
...(suggestions ? {suggestions} : {}),
Expand Down Expand Up @@ -903,6 +969,60 @@ function getCustomInput(
return isCustomInputIdentifier(name) ? CUSTOM_INPUT_IDENTIFIERS[name] : undefined;
}

/**
* Returns the custom input kind implied by a *declared* type when that type is a
* type parameter constrained by a custom-input data type — or undefined
* otherwise.
*
* Example: a REST trigger `<T extends TYPE>(...): REST_ADAPTER_INPUT<T>`. Once
* the call is resolved (e.g. `REST_ADAPTER_INPUT<number>`), the `payload`
* property's type is the concrete argument (`number`) and has lost the TYPE
* alias, so {@link getCustomInput} can no longer recover it. The signature's
* *declared* return type, however, still carries the type parameter `T` whose
* constraint is TYPE — so the custom input is recovered from there while the
* concrete instantiated type is still rendered as the schema's `type` (see the
* `declaredType` threading in {@link getSchema}).
*/
function getCustomInputFromConstraint(
checker: ts.TypeChecker,
type: ts.Type,
): (typeof CUSTOM_INPUT_IDENTIFIERS)[CustomInputIdentifier] | undefined {
if ((type.flags & ts.TypeFlags.TypeParameter) === 0) return undefined;

const typeParamDecl = type.symbol?.declarations?.[0];
if (
!typeParamDecl ||
!ts.isTypeParameterDeclaration(typeParamDecl) ||
!typeParamDecl.constraint
) {
return undefined;
}

// getTypeFromTypeNode (not getBaseConstraintOfType) so the constraint's
// alias name survives — the same reason the type-parameter branch of
// getSchema resolves constraints this way.
return getCustomInput(checker.getTypeFromTypeNode(typeParamDecl.constraint));
}

/**
* Resolves the type of a named property on the declared type, or undefined when
* it has no such property. Used to walk the declared type in step with the
* concrete type so a custom-input-constrained type parameter (see
* {@link getCustomInputFromConstraint}) can still be recovered from the
* declaration after instantiation.
*/
function getDeclaredPropertyType(
checker: ts.TypeChecker,
declaredType: ts.Type,
propertyName: string,
): ts.Type | undefined {
const symbol = checker.getPropertyOfType(declaredType, propertyName);
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
return declaration
? checker.getTypeOfSymbolAtLocation(symbol!, declaration)
: undefined;
}

/**
* Checks whether a type is the FILE data type.
*
Expand Down
Loading
Loading