kalup

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 }
ArgumentWhat it is
firstThe object's HubSpot name, such as 'companies'
groupsOptional. Property groups this file owns: { <group name>: { label: '...' } }
propertiesRequired. { <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(),
  },
})
FieldRequiredWhat it is
labelsyes{ singular, plural }, the names people see
primaryDisplayPropertyyesThe internal name of the property shown as the record name
requiredPropertiesnoInternal names that every record must have
searchablePropertiesnoInternal names HubSpot search covers
secondaryDisplayPropertiesnoInternal 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.

BuilderHubSpot typeAllowed fieldTypeTypeScript typeOn the wire
p.stringstringtext, textarea, file, phonenumber, html (rich text), calculation_equationstring | nullPassed through
p.numbernumbernumber, calculation_equationnumber | nullNumber(value); a value that is not a number throws
p.booleanboolbooleancheckbox, calculation_equationboolean | null'true' and 'false'; anything else throws
p.phoneNumberphone_numberphonenumberstring | nullPassed through
p.ownerenumeration, options filled by HubSpot with the account's usersselect, radiostring | nullThe owner ID, passed through
p.datedatedatestring | nullISO YYYY-MM-DD, passed through
p.datetimedatetimedatestring | nullISO 8601 UTC, passed through
p.enumenumerationselect, radio, booleancheckbox, calculation_equationunion of the aliases, Unlisted, or nullA stored value that is not an option reads as Unlisted; with .strict() it throws
p.multiEnumenumerationcheckboxarray of aliases and Unlisted, or null;-separated, each member read as p.enum reads one value
p.stringArraystringtext, textarea, file, phonenumberstring[] | nullReads split on , or ;, trimmed, empties dropped. Writes ,-joined
p.jsonstringtext, textarea, file, phonenumberthe schema's output, or nullJSON.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:

FieldRequiredWhat it is
labelyesThe name people see
groupyesA key under the file's groups. An unknown group is E_UNKNOWN_GROUP
fieldTypeyesThe input, from the builder's list
descriptionnoThe help text
optionsfor enumsFor p.enum and p.multiEnum. Order is display order
hasUniqueValuenoValues must be unique across records. Set on create only
formFieldnoThe property can appear on forms
hiddennoHubSpot hides the property
displayOrdernoIts place in the group: lowest positive first, -1 after the rest
numberDisplayHintnop.number: formatted, unformatted, currency, percentage, duration or probability
showCurrencySymbolnop.number: show the account's currency symbol
currencyPropertyNamenop.number: the property holding the currency code. Needs showCurrencySymbol: true
textDisplayHintnoText builders and p.phoneNumber: unformatted_single_line, multi_line, email, phone_number, domain_name, ip_address, physical_address or postal_code
calculationFormulanoThe formula of a calculation, with fieldType: 'calculation_equation'
dataSensitivitynonon_sensitive, sensitive or highly_sensitive. Set on create only
lifecyclenoHow 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: hasUniqueValue and dataSensitivity. HubSpot keeps them whatever a later update says, so plan blocks a difference in either and names the migration. The owner link of a p.owner is the same.
  • Changed, never removed: numberDisplayHint and textDisplayHint can 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.
  • showCurrencySymbol cannot go back to false while currencyPropertyName is set. Whether anything clears currencyPropertyName is 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 },
]
FieldRequiredWhat it is
valueyesWhat HubSpot stores
labelyesWhat people see
asnoThe app-side alias. Never sent to HubSpot
hiddennoHidden from people in HubSpot
descriptionnoThe 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:

ChainWhat 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 }
FieldDefaultWhat 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
removedOptionsnoneValues to remove under 'additive'
ignoreChangesnoneDefinition fields set on create and then left to the portal
preventDestroyfalseMakes 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:

NameExampleWhoseCan change
TypeScript keybillingStatusyoursyes, freely
Internal name'billing_status'HubSpot'sno, HubSpot never renames it
Label'Billing status'the portal'syes, 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.

On this page