CT vs Helm
At a glance
Section titled “At a glance”| Feature | CT | Helm |
|---|---|---|
| Language | TypeScript | Go templates |
| IDE support | Full IntelliSense, autocomplete, jump-to-definition | None (text templates) |
| Type safety | Compile-time — Zod validation + generated .d.ts | Runtime only — silent failures on typos |
| Values | Plain JS objects, typed with DeepPartial<T> | Flat YAML values.yaml with no schema by default |
| Logic | Real functions, loops, imports | {{ if }}, {{ range }}, {{ include }} |
| Reuse | ES module imports from any Git URL | Chart dependencies, subcharts, library charts |
| Packaging | Git repo + tag — no registry needed | OCI / ChartMuseum registry |
| Learning curve | Know TypeScript? You’re done. | Go template DSL, sprig, helpers, _helpers.tpl |
| Output | ct template → YAML | helm template → YAML |
Go templates vs TypeScript
Section titled “Go templates vs TypeScript”CT
Helm
import { ingress } from "github.com/cloudticon/k8s@master";
if (Values.ingress?.enabled) { ingress({ name: Values.name, labels: Values.labels, annotations: Values.ingress.annotations, tls: Values.ingress.tls, rules: Values.ingress.hosts.map(h => ({ host: h.host, paths: h.paths.map(p => ({ path: p.path, pathType: p.pathType, service: Values.name, port: p.port, })), })), });}{{- if .Values.ingress.enabled -}}apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: {{ include "myapp.fullname" . }} labels: {{- include "myapp.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }}spec: {{- if .Values.ingress.tls }} tls: {{- range .Values.ingress.tls }} - hosts: {{- range .hosts }} - {{ . | quote }} {{- end }} secretName: {{ .secretName }} {{- end }} {{- end }} rules: {{- range .Values.ingress.hosts }} - host: {{ .host | quote }} http: paths: {{- range .paths }} - path: {{ .path }} pathType: {{ .pathType }} backend: service: name: {{ include "myapp.fullname" $ }} port: number: {{ .port }} {{- end }} {{- end }}{{- end }}Real if, real .map(), real variables. No nindent, no toYaml, no include.
Values: typed vs untyped
Section titled “Values: typed vs untyped”CT — values.json + generated types
Helm — values.yaml
// values.json{ "replicas": 3, "image": "nginx:1.25", "host": "app.example.com"}
// auto-generated by ct typesinterface CtValues { replicas: number; image: string; host: string;}replicaCount: 3image: repository: nginx tag: "1.25" pullPolicy: IfNotPresentservice: type: ClusterIP port: 80ingress: enabled: true hosts: - host: app.example.com paths: - path: / pathType: Prefix port: 80Typo in replicaCount? Helm won’t tell you — it silently uses the default. With CT you get an immediate red squiggly in VS Code. No deploy needed.
Reuse: imports vs subcharts
Section titled “Reuse: imports vs subcharts”CT
Helm
import { createPostgres } from "github.com/my-org/k8s-db@v2.0.0/postgres";import { createRedis } from "github.com/my-org/k8s-cache@v1.3.0/redis";
createPostgres({ name: "db", instances: 2 });createRedis({ name: "cache", size: "1Gi" });charts/ postgresql/ Chart.yaml values.yaml templates/ deployment.yaml service.yaml ... redis/ Chart.yaml ...Chart.yaml # dependencies blockvalues.yaml # override nested subchart valuesUpdating a subchart means helm dependency update, version pinning, repository registration. Breaking changes cascade through values.yaml overrides. CT uses standard ES imports — pin to a tag, update with a one-line version bump. No registry, no Chart.yaml, no dependency update dance.
Factory composition vs _helpers.tpl
Section titled “Factory composition vs _helpers.tpl”CT
Helm
function appName(name: string): string { return `app-${name}`.slice(0, 63);}
function labels(name: string) { return { "app.kubernetes.io/name": appName(name), "app.kubernetes.io/managed-by": "ct", };}{{- define "myapp.fullname" -}}{{- if .Values.fullnameOverride }}{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}{{- else }}{{- $name := default .Chart.Name .Values.nameOverride }}{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}{{- end }}{{- end }}
{{- define "myapp.labels" -}}helm.sh/chart: {{ include "myapp.chart" . }}app.kubernetes.io/name: {{ include "myapp.name" . }}app.kubernetes.io/instance: {{ .Release.Name }}{{- end }}Standard TypeScript. Testable. Importable. No magic.
When Helm still makes sense
Section titled “When Helm still makes sense”- You consume third-party charts (Bitnami, Prometheus community) — they ship as Helm charts.
- Your org already has a large Helm-based GitOps pipeline and migration cost is high.
- You need Helm hooks (
pre-install,post-upgrade) for migration jobs.
CT can coexist — render CT manifests into a GitOps repo alongside Helm releases.
Migration path
Section titled “Migration path”- Keep existing Helm charts running.
- Start new services with
ct init. - Extract shared patterns into CT factory packages.
- Gradually replace Helm charts with CT when they need changes anyway.