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
29 changes: 5 additions & 24 deletions src/extraction/getTypeFromValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export const getTypeFromValue = (
const visit = (node: ts.Node) => {
if (ts.isVariableDeclaration(node) && node.name.getText() === "tempValue") {
const type = checker.getTypeAtLocation(node);
const allAliases = checker.getSymbolsInScope(node, ts.SymbolFlags.TypeAlias);

const resolve = (t: ts.Type): string => {
if (t.isUnion()) {
Expand All @@ -60,29 +59,11 @@ export const getTypeFromValue = (
}
}

// 4. Alias-Suche für Blätter (Zahlen, Strings, etc.)
const matches = allAliases.filter(s =>
checker.isTypeAssignableTo(t, checker.getDeclaredTypeOfSymbol(s))
);

if (matches.length > 0) {
const bestMatch = matches.sort((a, b) => {
const typeA = checker.getDeclaredTypeOfSymbol(a);
const typeB = checker.getDeclaredTypeOfSymbol(b);

const aSubB = checker.isTypeAssignableTo(typeA, typeB);
const bSubA = checker.isTypeAssignableTo(typeB, typeA);

if (aSubB && !bSubA) return -1;
if (bSubA && !aSubB) return 1;

return a.getName().length - b.getName().length || a.getName().localeCompare(b.getName());
})[0];

return bestMatch.getName();
}

return checker.typeToString(t, node);
// 4. Leaves (numbers, strings, booleans, ...): widen the literal
// type to its base type (200 -> number, "POST" -> string) and emit
// the structural TypeScript type rather than a DataType alias.
const base = checker.getBaseTypeOfLiteralType(t);
return checker.typeToString(base, node);
};

inferredType = resolve(type);
Expand Down
66 changes: 66 additions & 0 deletions src/util/schema.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,34 @@ import {
import {getSubFlows} from "./subflows.util";


/**
* Maps a data type identifier to a dedicated input kind.
*
* These are semantic data types whose underlying (structural) type does not by
* itself convey the custom input they need — e.g. DATE is a number underneath
* but should render a date picker. The mapping is keyed on the identifier alone:
* the resulting schema carries only the input kind (plus the usual type and
* suggestions), nothing derived from the structure.
*
* Adding a new mapping is a one-line change here. The rest of the pipeline reads
* from this registry: the declaration layer brands each identifier so its alias
* name survives type resolution (see {@link getSharedTypeDeclarations}), the
* schema layer surfaces the mapped input, and the value/inference layers treat
* it as its underlying type.
*/
export const CUSTOM_INPUT_IDENTIFIERS = {
DATE: "date",
} as const satisfies Record<string, string>;

/** A data type identifier that maps to a custom input. */
export type CustomInputIdentifier = keyof typeof CUSTOM_INPUT_IDENTIFIERS;

/** Returns true if the given identifier maps to a custom input. */
export const isCustomInputIdentifier = (
identifier: string | null | undefined,
): identifier is CustomInputIdentifier =>
identifier != null && identifier in CUSTOM_INPUT_IDENTIFIERS;

/**
* Base interface for all input types.
* Provides common properties for suggestions and input metadata.
Expand Down Expand Up @@ -53,6 +81,15 @@ export interface PrimitiveInput extends Input {
input?: "boolean" | "number" | "text" | "select";
}

/**
* Represents a date input type.
* Emitted for the DATE data type so the UI can render a dedicated date picker
* instead of the plain number input its underlying type would otherwise produce.
*/
export interface DateInput extends Input {
input?: "date";
}

/**
* Represents a data object input type with structured properties.
* Includes property definitions and required field tracking.
Expand Down Expand Up @@ -93,6 +130,7 @@ export interface TypeInput extends Input {
*/
export type Schema =
| PrimitiveInput
| DateInput
| DataInput
| ListInput
| TypeInput
Expand Down Expand Up @@ -202,6 +240,16 @@ export const getSchema = (
}
}

// Custom-input data types (e.g. DATE) would otherwise resolve to the input
// of their underlying type (a number, for DATE). They are branded (see
// getSharedTypeDeclarations) so their alias name survives; detect it here and
// surface the mapped input instead. Checked before the primitive checks since
// the underlying type is often a primitive.
const customInput = getCustomInput(parameterType);
if (customInput) {
return {input: customInput, type, ...combinedSuggestions};
}

// Boolean is internally represented by TypeScript as the union `true | false`,
// so it must be detected before the primitive-literal-union check below; otherwise
// `boolean` (and `true | false`) would incorrectly surface as a select.
Expand Down Expand Up @@ -483,6 +531,24 @@ const demoteSelect = (schema: Schema): Schema => {
};
};

/**
* Returns the custom input kind for a type, or undefined if it maps to none.
*
* Custom-input data types are branded (e.g. `number & {}`) so their alias name
* survives type resolution — TypeScript otherwise discards the name of bare
* primitive aliases. The preserved alias name is looked up in
* {@link CUSTOM_INPUT_IDENTIFIERS}.
*
* @param type - The type to check
* @returns The mapped input kind (e.g. "date"), or undefined
*/
function getCustomInput(
type: ts.Type,
): (typeof CUSTOM_INPUT_IDENTIFIERS)[CustomInputIdentifier] | undefined {
const name = type.aliasSymbol?.getName();
return isCustomInputIdentifier(name) ? CUSTOM_INPUT_IDENTIFIERS[name] : undefined;
}

/**
* Checks if a type is a boolean type (either boolean or boolean literal).
*
Expand Down
16 changes: 13 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import ts from "typescript";
import {createSystem, createVirtualTypeScriptEnvironment, VirtualTypeScriptEnvironment} from "@typescript/vfs"
import {stringify} from "lossless-json";
import {isCustomInputIdentifier} from "./util/schema.util";

/**
* Result of a node or flow validation.
Expand Down Expand Up @@ -86,9 +87,18 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s
.map(g => `type ${g} = ${genericType};`)
.join("\n");

const typeAliasDeclarations = dataTypes?.map(dt =>
`type ${dt.identifier}${(dt.genericKeys?.length ?? 0) > 0 ? `<${dt.genericKeys?.join(",")}>` : ""} = ${dt.type};`
).join("\n");
const typeAliasDeclarations = dataTypes?.map(dt => {
const generics = (dt.genericKeys?.length ?? 0) > 0 ? `<${dt.genericKeys?.join(",")}>` : "";
// Custom-input data types (e.g. DATE) map to a dedicated input based on
// their identifier alone. TypeScript discards the alias name of bare
// primitive aliases (`type DATE = number` resolves to plain `number`),
// which would make them indistinguishable from their underlying type.
// Branding with an empty intersection keeps the alias name on the resolved
// type — staying mutually assignable with the base type — so the schema
// layer can recover the identifier and surface the mapped input.
const type = isCustomInputIdentifier(dt.identifier) ? `${dt.type} & {}` : dt.type;
return `type ${dt.identifier}${generics} = ${type};`;
}).join("\n");

// Pre-instantiate every generic type with `any` for each type parameter.
// These are used by widenForSuggestions to produce the widened type for
Expand Down
50 changes: 50 additions & 0 deletions test/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,56 @@ export const DATA_TYPES: DataType[] = [
"__typename": "PageInfo"
}
}
},
{
"__typename": "DataType",
"id": "gid://sagittarius/DataType/10",
"createdAt": "2026-06-19T15:33:16Z",
"updatedAt": "2026-06-19T15:34:59Z",
"identifier": "DATE",
"genericKeys": [],
"type": "number",
"definitionSource": "",
"version": "0.0.0",
"name": [
{
"__typename": "Translation",
"code": "en-US",
"content": "Date"
}
],
"aliases": [
{
"__typename": "Translation",
"code": "en-US",
"content": "date;day;day of month;calendar day"
}
],
"displayMessages": [
{
"__typename": "Translation",
"code": "en-US",
"content": "Date"
}
],
"runtime": {
"id": "gid://sagittarius/Runtime/1",
"__typename": "Runtime"
},
"runtimeModule": {
"__typename": "RuntimeModule",
"id": "gid://sagittarius/RuntimeModule/10"
},
"rules": {
"__typename": "DataTypeRuleConnection",
"count": 0,
"nodes": [],
"pageInfo": {
"endCursor": null,
"hasNextPage": false,
"__typename": "PageInfo"
}
}
}
];

Expand Down
29 changes: 29 additions & 0 deletions test/schema/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,35 @@ describe("Schema", () => {
});
});

describe("DATE data type", () => {
// DATE is declared as `type DATE = number`, but the schema layer must
// surface a dedicated date input instead of the number input its
// underlying type would otherwise produce.
it("resolves to a date input", () => {
expect(getTypeSchema("DATE", DATA_TYPES)).toEqual({
input: "date",
type: "DATE",
});
});

it("resolves to a date input when nested in a list and object", () => {
const list = getTypeSchema("LIST<DATE>", DATA_TYPES) as any;
expect(list.input).toBe("list");
expect(list.items[0]).toEqual({input: "date", type: "DATE"});

const object = getTypeSchema("{ created: DATE }", DATA_TYPES) as any;
expect(object.input).toBe("data");
expect(object.properties.created).toEqual({input: "date", type: "DATE"});
});

it("still resolves a plain NUMBER to a number input", () => {
expect(getTypeSchema("NUMBER", DATA_TYPES)).toEqual({
input: "number",
type: "number",
});
});
});

describe("union-typed property (string | nested object) reference suggestions", () => {
// A custom datatype that is an object. One of its keys, `flexible`, is a
// union of a plain string (TEXT) or a nested object. The nested object in
Expand Down
18 changes: 9 additions & 9 deletions test/typeFromValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { DATA_TYPES } from './data';
describe('getTypeFromValue', () => {

it('should infer exact string literals', () => {
expect(getTypeFromValue({value: "POST"}, DATA_TYPES)).toBe('HTTP_METHOD');
expect(getTypeFromValue({value: "active"}, DATA_TYPES)).toBe('TEXT');
expect(getTypeFromValue({value: "POST"}, DATA_TYPES)).toBe('string');
expect(getTypeFromValue({value: "active"}, DATA_TYPES)).toBe('string');
});

it('should infer exact number and boolean literals', () => {
expect(getTypeFromValue({value: 200}, DATA_TYPES)).toBe('NUMBER');
expect(getTypeFromValue({value: false}, DATA_TYPES)).toBe('BOOLEAN');
expect(getTypeFromValue({value: 200}, DATA_TYPES)).toBe('number');
expect(getTypeFromValue({value: false}, DATA_TYPES)).toBe('boolean');
});

it('should infer complex object structures', () => {
Expand All @@ -23,22 +23,22 @@ describe('getTypeFromValue', () => {

const result = getTypeFromValue({value: user}, DATA_TYPES);

expect(result).toMatch(/id:\s*NUMBER/);
expect(result).toMatch(/name:\s*TEXT/);
expect(result).toMatch(/age:\s*NUMBER/);
expect(result).toMatch(/id:\s*number/);
expect(result).toMatch(/name:\s*string/);
expect(result).toMatch(/age:\s*number/);
});

it('should infer arrays as unions of literals', () => {
const list = ["A", "B"];
const result = getTypeFromValue({value: list}, DATA_TYPES);

expect(result).toBe("TEXT[]");
expect(result).toBe("string[]");
});

it('should handle nested arrays', () => {
const nested = [1, [2, 3]];
const result = getTypeFromValue({value: nested}, DATA_TYPES);
expect(result).toBe('(NUMBER | NUMBER[])[]');
expect(result).toBe('(number | number[])[]');
});

it('should handle null and empty structures', () => {
Expand Down