CrystalFlow uses TypeScript decorators to define nodes declaratively. Decorators provide metadata about nodes, inputs, outputs, and properties without cluttering your business logic.
What are Decorators?
Decorators are special annotations that add metadata to classes and properties:
@ defineNode ({ type: 'math.add' , label: 'Add' })
class AddNode extends Node {
@ Input ({ type: 'number' , label: 'A' })
a : number = 0 ;
@ Output ({ type: 'number' , label: 'Result' })
result : number ;
}
Decorators are a TypeScript feature that requires experimentalDecorators: true in your tsconfig.json
@defineNode
The @defineNode decorator registers a node class with CrystalFlow:
@ defineNode ({
type: 'category.nodename' ,
label: 'Display Name' ,
category: 'Category' ,
description: 'Optional description'
})
class MyNode extends Node {
// ...
}
Parameters
Unique identifier for the node type (e.g., 'math.add', 'http.request') Convention: Use dot notation like category.operation
Human-readable name shown in the UI
Category for organizing nodes in the palette (e.g., 'Math', 'Data', 'Network')
Optional description explaining what the node does
Example
@ defineNode ({
type: 'string.uppercase' ,
label: 'To Uppercase' ,
category: 'String' ,
description: 'Converts a string to uppercase'
})
class UppercaseNode extends Node {
execute () {
this . output = this . input . toUpperCase ();
}
}
The @Input decorator defines an input port on a node:
@ Input ({
type: 'string' ,
label: 'Message' ,
defaultValue: 'Hello' ,
required: false ,
description: 'Input message'
})
message : string = 'Hello' ;
Parameters
Data type of the input: 'string', 'number', 'boolean', 'any', or custom types
Display name for the input port in the UI
Default value if no connection is made
Whether this input must be connected or have a value
Optional description of the input’s purpose
Example
@ defineNode ({
type: 'math.power' ,
label: 'Power' ,
category: 'Math'
})
class PowerNode extends Node {
@ Input ({
type: 'number' ,
label: 'Base' ,
defaultValue: 2 ,
description: 'The base number'
})
base : number = 2 ;
@ Input ({
type: 'number' ,
label: 'Exponent' ,
defaultValue: 2 ,
description: 'The power to raise to'
})
exponent : number = 2 ;
@ Output ({ type: 'number' , label: 'Result' })
result : number ;
execute () {
this . result = Math . pow ( this . base , this . exponent );
}
}
@Output
The @Output decorator defines an output port on a node:
@ Output ({
type: 'string' ,
label: 'Result' ,
description: 'The processed result'
})
result : string ;
Parameters
Data type of the output: 'string', 'number', 'boolean', 'any', or custom types
Display name for the output port in the UI
Optional description of what the output represents
Example
@ defineNode ({
type: 'http.request' ,
label: 'HTTP Request' ,
category: 'Network'
})
class HttpRequestNode extends Node {
@ Output ({
type: 'any' ,
label: 'Response' ,
description: 'HTTP response data'
})
response : any ;
@ Output ({
type: 'number' ,
label: 'Status Code' ,
description: 'HTTP status code'
})
statusCode : number ;
@ Output ({
type: 'any' ,
label: 'Headers' ,
description: 'Response headers'
})
headers : any ;
async execute () {
const res = await fetch ( this . url );
this . response = await res . json ();
this . statusCode = res . status ;
this . headers = Object . fromEntries ( res . headers . entries ());
}
}
@Property
The @Property decorator defines a static configuration value (not connected to other nodes):
@ Property ({
type: 'select' ,
label: 'Operation' ,
defaultValue: 'add' ,
options: [
{ value: 'add' , label: 'Add' },
{ value: 'subtract' , label: 'Subtract' }
],
description: 'The operation to perform'
})
operation : string = 'add' ;
Parameters
type
'string' | 'number' | 'boolean' | 'select'
required
The property type
Display label in the properties panel
Default value for the property
Whether the property is required
Description shown in the UI
options
Array<{value: any, label: string}>
Options for select type properties
Minimum value for number properties
Maximum value for number properties
Step increment for number properties
Example
@ defineNode ({
type: 'data.filter' ,
label: 'Filter Array' ,
category: 'Data'
})
class FilterNode extends Node {
@ Property ({
type: 'select' ,
label: 'Condition Type' ,
defaultValue: 'equals' ,
options: [
{ value: 'equals' , label: 'Equals' },
{ value: 'contains' , label: 'Contains' },
{ value: 'greater' , label: 'Greater Than' }
]
})
conditionType : string = 'equals' ;
@ Property ({
type: 'string' ,
label: 'Field Name' ,
defaultValue: 'id' ,
description: 'The field to filter by'
})
fieldName : string = 'id' ;
@ Property ({
type: 'number' ,
label: 'Max Results' ,
defaultValue: 100 ,
min: 1 ,
max: 1000 ,
step: 10
})
maxResults : number = 100 ;
@ Input ({ type: 'any[]' , label: 'Array' })
array : any [] = [];
@ Output ({ type: 'any[]' , label: 'Filtered' })
filtered : any [];
execute () {
// Use properties in logic
this . filtered = this . array
. filter ( item => this . applyCondition ( item ))
. slice ( 0 , this . maxResults );
}
}
Key Difference:
@Property = Static configuration in properties panel (not connected)
@Input = Dynamic data flow via connections
@Output = Computed results during execution
class ExampleNode extends Node {
// Property: Set once in UI, doesn't change
@ Property ({ type: 'string' , label: 'API Endpoint' })
endpoint : string = 'https://api.example.com' ;
// Input: Receives data from other nodes
@ Input ({ type: 'string' , label: 'Query' })
query : string = '' ;
// Output: Produces data for other nodes
@ Output ({ type: 'any' , label: 'Data' })
data : any ;
async execute () {
// Use property and input to produce output
const response = await fetch ( ` ${ this . endpoint } ?q= ${ this . query } ` );
this . data = await response . json ();
}
}
Decorator Inheritance
Properties and decorators are inherited from base classes:
// Base class with common properties
class BaseApiNode extends Node {
@ Property ({
type: 'boolean' ,
label: 'Enable Logging' ,
defaultValue: false
})
enableLogging : boolean = false ;
@ Property ({
type: 'number' ,
label: 'Timeout (ms)' ,
defaultValue: 30000
})
timeout : number = 30000 ;
}
// Child class inherits properties
@ defineNode ({
type: 'api.users' ,
label: 'Fetch Users' ,
category: 'API'
})
class FetchUsersNode extends BaseApiNode {
// Inherits enableLogging and timeout
@ Output ({ type: 'any[]' , label: 'Users' })
users : any [];
async execute () {
if ( this . enableLogging ) {
console . log ( 'Fetching users...' );
}
// Use inherited timeout property
// ...
}
}
TypeScript Configuration
To use decorators, configure your tsconfig.json:
{
"compilerOptions" : {
"experimentalDecorators" : true ,
"emitDecoratorMetadata" : true ,
"target" : "ES2020" ,
"module" : "ESNext"
}
}
Don’t forget reflect-metadata! Import reflect-metadata at the top of your entry file: import 'reflect-metadata' ;
Best Practices
Labels should be clear and concise - they’re shown to users in the UI.
Always add descriptions to help users understand what inputs, outputs, and properties do.
Provide default values that work out of the box.
Name node types with category prefixes: math.add, string.uppercase, http.request
Use consistent category names to group related nodes together.
Complete Example
Here’s a complete node using all decorators:
import 'reflect-metadata' ;
import { Node , defineNode , Property , Input , Output } from '@crystalflow/core' ;
@ defineNode ({
type: 'text.replace' ,
label: 'Replace Text' ,
category: 'Text' ,
description: 'Replace occurrences of text in a string'
})
export class ReplaceTextNode extends Node {
// Property: Configuration
@ Property ({
type: 'boolean' ,
label: 'Case Sensitive' ,
defaultValue: true ,
description: 'Whether matching should be case sensitive'
})
caseSensitive : boolean = true ;
@ Property ({
type: 'boolean' ,
label: 'Replace All' ,
defaultValue: true ,
description: 'Replace all occurrences or just the first'
})
replaceAll : boolean = true ;
// Inputs: Dynamic data
@ Input ({
type: 'string' ,
label: 'Text' ,
required: true ,
description: 'The text to search in'
})
text : string = '' ;
@ Input ({
type: 'string' ,
label: 'Find' ,
required: true ,
description: 'The text to find'
})
find : string = '' ;
@ Input ({
type: 'string' ,
label: 'Replace' ,
defaultValue: '' ,
description: 'The replacement text'
})
replace : string = '' ;
// Outputs: Results
@ Output ({
type: 'string' ,
label: 'Result' ,
description: 'The text with replacements made'
})
result : string ;
@ Output ({
type: 'number' ,
label: 'Count' ,
description: 'Number of replacements made'
})
count : number ;
// Business logic
execute () {
let searchText = this . text ;
let findText = this . find ;
if ( ! this . caseSensitive ) {
searchText = searchText . toLowerCase ();
findText = findText . toLowerCase ();
}
if ( this . replaceAll ) {
const regex = new RegExp (
findText . replace ( / [ .*+?^${}()|[ \]\\ ] / g , ' \\ $&' ),
this . caseSensitive ? 'g' : 'gi'
);
this . result = this . text . replace ( regex , this . replace );
this . count = ( this . text . match ( regex ) || []). length ;
} else {
const index = searchText . indexOf ( findText );
if ( index !== - 1 ) {
this . result =
this . text . slice ( 0 , index ) +
this . replace +
this . text . slice ( index + this . find . length );
this . count = 1 ;
} else {
this . result = this . text ;
this . count = 0 ;
}
}
}
}
Next Steps