Property builders
defineObject, defineCustomObject, every p.<kind> builder, definitions, options and aliases, chains and lifecycle
An object file declares one or more objects with defineObject or defineCustomObject, and each property with a p.<kind>(...) builder from @kalup/core. This page is the reference for all of them. Config files covers the files and the grammar they follow.
defineObject
For a standard object: companies, contacts, deals, tickets and the others.
import { defineObject, type InferProperties, p } from '@kalup/core'
export const Company = defineObject('companies', {
groups: {
billing: { label: 'Billing' },
},
properties: {
billingStatus: p.enum('billing_status', {
label: 'Billing status',
group: 'billing',
fieldType: 'select',
options: [
{ value: 'active', label: 'Active' },
{ value: 'PAST DUE', label: 'Past due', as: 'past_due' },
],
}),
name: p.string('name'),
},
})
export type CompanyData = InferProperties<typeof Company.properties> & { id: string }| Argument | What it is |
|---|---|
| first | The object's HubSpot name, such as 'companies' |
groups | Optional. Property groups this file owns: { <group name>: { label: '...' } } |
properties | Required. { <TypeScript key>: p.<kind>(...) } |
The type line after it is optional and written by pull. It must read exactly export type <Name>Data = InferProperties<typeof <Name>.properties> & { id: string }, once per export.
defineCustomObject
For a custom object schema. It takes the same groups and properties, plus the schema fields:
import { defineCustomObject, type InferProperties, p } from '@kalup/core'
export const Subscription = defineCustomObject('subscription', {
labels: { singular: 'Subscription', plural: 'Subscriptions' },
primaryDisplayProperty: 'plan_name',
requiredProperties: ['plan_name'],
searchableProperties: ['plan_name'],
groups: {
subscription_information: { label: 'Subscription information' },
},
properties: {
planName: p
.string('plan_name', {
label: 'Plan name',
group: 'subscription_information',
fieldType: 'text',
hasUniqueValue: true,
})
.required(),
},
})| Field | Required | What it is |
|---|---|---|
labels | yes | { singular, plural }, the names people see |
primaryDisplayProperty | yes | The internal name of the property shown as the record name |
requiredProperties | no | Internal names that every record must have |
searchableProperties | no | Internal names HubSpot search covers |
secondaryDisplayProperties | no | Internal names shown under the record name |
A custom object without labels or primaryDisplayProperty is E_NOT_DATA.
The builders
Every builder takes the internal name first and an optional definition. The builder decides the HubSpot type (never written in config), the fieldType values it accepts, and the TypeScript type your app sees.
| Builder | HubSpot type | Allowed fieldType | TypeScript type | On the wire |
|---|---|---|---|---|
p.string | string | text, textarea, file, phonenumber, html (rich text), calculation_equation | string | null | Passed through |
p.number | number | number, calculation_equation | number | null | Number(value); a value that is not a number throws |
p.boolean | bool | booleancheckbox, calculation_equation | boolean | null | 'true' and 'false'; anything else throws |
p.phoneNumber | phone_number | phonenumber | string | null | Passed through |
p.owner | enumeration, options filled by HubSpot with the account's users | select, radio | string | null | The owner ID, passed through |
p.date | date | date | string | null | ISO YYYY-MM-DD, passed through |
p.datetime | datetime | date | string | null | ISO 8601 UTC, passed through |
p.enum | enumeration | select, radio, booleancheckbox, calculation_equation | union of the aliases, Unlisted, or null | A stored value that is not an option reads as Unlisted; with .strict() it throws |
p.multiEnum | enumeration | checkbox | array of aliases and Unlisted, or null | ;-separated, each member read as p.enum reads one value |
p.stringArray | string | text, textarea, file, phonenumber | string[] | null | Reads split on , or ;, trimmed, empties dropped. Writes ,-joined |
p.json | string | text, textarea, file, phonenumber | the schema's output, or null | JSON.parse, then the schema validates |
For every builder, a missing, null or blank value reads as null. A fieldType outside the builder's list is E_TYPE_FIELDTYPE, and the fix lists the allowed values. A name that is not in this table, such as p.text, is E_UNKNOWN_BUILDER.
p.stringArray and p.json are yours to write: HubSpot has no such types, so pull never emits them, and it keeps them once you do.
One example per builder
The properties block of a file whose groups declare billing and that imports the rowMeta schema:
properties: {
notes: p.string('billing_notes', { label: 'Billing notes', group: 'billing', fieldType: 'textarea' }),
seats: p.number('seat_count', { label: 'Seat count', group: 'billing', fieldType: 'number' }),
autoRenew: p.boolean('auto_renew', { label: 'Auto renew', group: 'billing', fieldType: 'booleancheckbox' }),
renewalDate: p.date('renewal_date', { label: 'Renewal date', group: 'billing', fieldType: 'date' }),
lastSyncedAt: p.datetime('last_synced_at', { label: 'Last synced at', group: 'billing', fieldType: 'date' }),
billingStatus: p.enum('billing_status', {
label: 'Billing status',
group: 'billing',
fieldType: 'select',
options: [
{ value: 'active', label: 'Active' },
{ value: 'PAST DUE', label: 'Past due', as: 'past_due' },
],
}),
channels: p.multiEnum('billing_channels', {
label: 'Billing channels',
group: 'billing',
fieldType: 'checkbox',
options: [
{ value: 'email', label: 'Email' },
{ value: 'post', label: 'Post' },
],
}),
tags: p.stringArray('billing_tags', { label: 'Billing tags', group: 'billing', fieldType: 'text' }),
rowMeta: p.json('row_meta', rowMeta, { label: 'Row meta', group: 'billing', fieldType: 'textarea' }),
hotline: p.phoneNumber('billing_hotline', { label: 'Billing hotline', group: 'billing', fieldType: 'phonenumber' }),
accountManager: p.owner('account_manager', { label: 'Account manager', group: 'billing', fieldType: 'select' }),
}p.owner takes no options: HubSpot fills them with the account's users, and Kalup sends externalOptions: true and referencedObjectType: 'OWNER' when it creates the property. A p.boolean is created with the options true and false, which HubSpot requires.
p.json
p.json(name, schema, definition?) takes a validator as its second argument. Any Standard Schema works, so you can use zod, valibot or your own. The schema must validate synchronously.
import { z } from 'zod'
import { defineObject, p } from '@kalup/core'
const rowMeta = z.object({ source: z.string(), importedAt: z.string() })
export const Company = defineObject('companies', {
groups: {
billing: { label: 'Billing' },
},
properties: {
rowMeta: p.json('row_meta', rowMeta, { label: 'Row meta', group: 'billing', fieldType: 'textarea' }),
},
})The reader keeps the validator as source text, along with the import it needs, so the CLI never runs it. A p.json without a validator is E_NOT_DATA. A fieldType other than textarea gets the warning W_JSON_FIELDTYPE: JSON text belongs in a textarea.
Definitions and references
A builder with no definition is a reference: p.string('name'). It types the property in your app and nothing else. Kalup never creates, changes or removes it. Pull writes HubSpot-defined and calculated properties this way, and adds .readonly() wherever HubSpot marks the value read-only (a calculated property, hs_object_id).
Pull also writes a property Kalup does not write as a p.string reference, with W_UNSUPPORTED_TYPE: an externalOptions property that is not an owner select or radio (a multi-owner checkbox, or options from elsewhere); an object_coordinates or json type; and a custom property whose fieldType no builder takes, such as a rollup (calculation_rollup). Plan never creates, changes or archives one, and blocks a full definition of one. A reference you wrote for one keeps its builder, so p.enum('hubspot_owner_id') stays.
A builder with label, group and fieldType is managed. The definition holds enough to create the property from nothing, in HubSpot's terms:
| Field | Required | What it is |
|---|---|---|
label | yes | The name people see |
group | yes | A key under the file's groups. An unknown group is E_UNKNOWN_GROUP |
fieldType | yes | The input, from the builder's list |
description | no | The help text |
options | for enums | For p.enum and p.multiEnum. Order is display order |
hasUniqueValue | no | Values must be unique across records. Set on create only |
formField | no | The property can appear on forms |
hidden | no | HubSpot hides the property |
displayOrder | no | Its place in the group: lowest positive first, -1 after the rest |
numberDisplayHint | no | p.number: formatted, unformatted, currency, percentage, duration or probability |
showCurrencySymbol | no | p.number: show the account's currency symbol |
currencyPropertyName | no | p.number: the property holding the currency code. Needs showCurrencySymbol: true |
textDisplayHint | no | Text builders and p.phoneNumber: unformatted_single_line, multi_line, email, phone_number, domain_name, ip_address, physical_address or postal_code |
calculationFormula | no | The formula of a calculation, with fieldType: 'calculation_equation' |
dataSensitivity | no | non_sensitive, sensitive or highly_sensitive. Set on create only |
lifecycle | no | How changes to it are handled, below |
The field names are HubSpot's, so its properties guide maps onto them. A field the builder or another field rules out is E_DEFINITION_FIELD. Every field updates in place, with these exceptions:
- Set on create only:
hasUniqueValueanddataSensitivity. HubSpot keeps them whatever a later update says, so plan blocks a difference in either and names the migration. The owner link of ap.owneris the same. - Changed, never removed:
numberDisplayHintandtextDisplayHintcan move to another value, but HubSpot takes no value that removes a hint. Deleting the field from the file stops Kalup owning it, and the portal keeps the hint. showCurrencySymbolcannot go back tofalsewhilecurrencyPropertyNameis set. Whether anything clearscurrencyPropertyNameis not verified yet.
HubSpot ignores dateDisplayHint altogether, so that is no field. HubSpot behaviour in the repository has the live evidence for each field.
A calculation reads naturally with the builder of its result type:
yieldShare: p
.number('yield_share', {
label: 'Yield share',
group: 'billing',
fieldType: 'calculation_equation',
calculationFormula: 'picked_count / planted_count',
numberDisplayHint: 'percentage',
})
.readonly(),HubSpot stores its own spelling of a formula (a+1 becomes a + 1), and pull writes that spelling. Until config holds it, plan notes "HubSpot stores ... when sent ..." after the first apply. A formula change is risky: HubSpot recalculates every record. Apply creates a calculation after the properties its formula names.
label, group and fieldType come together. A definition that has some of them but not all is E_REFERENCE_DEFINITION, and the TypeScript types reject it in your editor first.
A field that is present is owned: Kalup stores it, compares it and writes it. An omitted optional field belongs to the portal and is never stored, compared or written. Pull leaves HubSpot's defaults out when it first writes a property (an empty description, hasUniqueValue: false, formField: false, hidden: false, displayOrder: -1, numberDisplayHint: 'formatted'), so those fields stay the portal's. To own one, write it yourself; kalup fmt keeps it.
A managed property whose internal name starts with hs_ is E_HS_PREFIX. That prefix is HubSpot's own. Reference such a property instead.
Enum references
One reference carries data: an enum with only options.
lifecycleStage: p.enum('lifecyclestage', {
options: [
{ value: 'lead', label: 'Lead' },
{ value: 'customer', label: 'Customer' },
],
})The options exist so the app is typed. Nothing owns them, and pull refreshes them from the portal. An options-only definition on any builder other than p.enum or p.multiEnum is E_REFERENCE_DEFINITION.
Options and aliases
options: [
{ value: 'active', label: 'Active' },
{ value: 'PAST DUE', label: 'Past due', as: 'past_due' },
{ value: 'peak', label: 'Peak', hidden: true },
]| Field | Required | What it is |
|---|---|---|
value | yes | What HubSpot stores |
label | yes | What people see |
as | no | The app-side alias. Never sent to HubSpot |
hidden | no | Hidden from people in HubSpot |
description | no | The option's help text |
The TypeScript type of an enum is the union of as ?? value across its options, plus Unlisted. get returns the alias and set takes the alias and writes the value, so 'PAST DUE' in the portal is 'past_due' in your code. Aliases are yours: pull keeps them.
Unlisted values and .strict()
An admin can add an option in HubSpot, and the default additive lifecycle keeps it. So get returns a stored value the options do not list as it is, typed Unlisted (a branded string, exported from @kalup/core), and set writes an Unlisted value back unchanged. A bare reference such as p.enum('lifecyclestage') lists no options: its type is Unlisted | null, and it never throws.
import type { Unlisted } from '@kalup/core'
const tier = Company.properties.tier.get(record.properties) // 'gold' | 'silver' | Unlisted | null
switch (tier) {
case 'gold':
case 'silver':
break
default:
// null, or an option config does not list yet
}A plain string is not Unlisted, so a typo in set is still a type error. if (tier === 'gold') is false for an unlisted value, and an exhaustive switch must handle it. get throws in one case: a stored value that is not an option but equals another option's alias, since set would then write that option.
.strict() makes the codec throw on any value the options do not list, on get and on set, and narrows the type to the aliases alone. It is for p.enum and p.multiEnum only (E_BAD_CHAIN elsewhere), and needs options (E_STRICT_WITHOUT_OPTIONS without them). Pull keeps it, and plan ignores it.
Chains
A builder call may end in any of these, and the writer puts them in this order:
| Chain | What it does |
|---|---|
.strict() | p.enum and p.multiEnum only: get and set throw on a value the options do not list, and the type has no Unlisted |
.required() | The type drops | null, and get throws when the value is missing |
.readonly() | set is a type error. Nothing changes at run time |
.managed(false) | The definition stays for reference, and the property is left out of plan and apply |
A reference takes .strict(), .required() and .readonly() only: .managed(false) on a reference is E_REFERENCE_DEFINITION. Calling a chain twice, .managed(true), .strict() on another builder, or any other call after the builder is E_BAD_CHAIN.
Lifecycle
lifecycle: { options: 'additive', removedOptions: ['legacy'], ignoreChanges: ['description'], preventDestroy: true }| Field | Default | What it means |
|---|---|---|
options | 'additive' | 'additive': a plan adds options from config and keeps options that exist only in the portal. 'exact': it also removes portal-only options |
removedOptions | none | Values to remove under 'additive' |
ignoreChanges | none | Definition fields set on create and then left to the portal |
preventDestroy | false | Makes kalup rm refuse to write a destroy tombstone |
The loader lifts the block into the IR with defaults filled in. Under takeover, options defaults to 'exact' instead. plan and compare apply options, removedOptions and ignoreChanges, and apply writes what they plan. preventDestroy makes kalup rm refuse a destroy (E_PREVENT_DESTROY); use --release, or remove preventDestroy first.
validate rejects, as E_LIFECYCLE: a removedOptions value that is still in options, and an ignoreChanges name that is not a definition field (a field of the definition table above other than lifecycle). An options value other than 'additive' or 'exact' is E_NOT_DATA.
Keys and names
Each property has three names:
| Name | Example | Whose | Can change |
|---|---|---|---|
| TypeScript key | billingStatus | yours | yes, freely |
| Internal name | 'billing_status' | HubSpot's | no, HubSpot never renames it |
| Label | 'Billing status' | the portal's | yes, updates in place |
Pull gives a new property the camelCase of its internal name as its key. Two properties of one object with the same key is E_KEY_COLLISION. One internal name under two keys is E_DUPLICATE_KEY.
Kalup is an independent open-source project maintained by Scopious. It is not affiliated with, endorsed by, or sponsored by HubSpot, Inc. HubSpot is a registered trademark of HubSpot, Inc.