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, phonenumberstring | nullPassed through
p.numbernumbernumbernumber | nullNumber(value); a value that is not a number throws
p.booleanboolbooleancheckboxboolean | null'true' and 'false'; anything else throws
p.datedatedatestring | nullISO YYYY-MM-DD, passed through
p.datetimedatetimedatestring | nullISO 8601 UTC, passed through
p.enumenumerationselect, radio, booleancheckboxunion of the aliases, or nullA stored value that is not an option throws
p.multiEnumenumerationcheckboxarray of aliases, or null;-separated
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' }),
}

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, a calculated one with .readonly().

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
formFieldnoThe property can appear on forms
lifecyclenoHow changes to it are handled, below

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 will write it. An omitted optional field belongs to the portal and is never stored, compared or written. Defaults are left out of the files: an empty description, hasUniqueValue: false, formField: false.

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. 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.

Chains

A builder call may end in any of these, and the writer puts them in this order:

ChainWhat it does
.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 .required() and .readonly() only: .managed(false) on a reference is E_REFERENCE_DEFINITION. Calling a chain twice, .managed(true), 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 a later rm refuse to write a destroy tombstone

This version parses and validates the block and lifts it into the IR with defaults filled in. The commands that act on it (plan and apply) are not in this version yet.

validate rejects, as E_LIFECYCLE: a removedOptions value that is still in options, and an ignoreChanges name that is not a definition field (label, group, fieldType, description, options, hasUniqueValue, formField). 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