Repository navigation
Production Typed Migration
This guide is for teams migrating an existing Helm repository to Timonel while
explicitly forbidding addManifest(), addTemplateManifest(), any,
as unknown as ..., and equivalent coercion tricks.
The goal is not to reproduce template text line for line. The goal is to preserve the rendered Kubernetes behavior, chart packaging, values contract, and Helm lifecycle using typed TypeScript constructs wherever Timonel and its cdk8s dependencies provide a real typed path.
Use this order for every resource or chart feature:
-
cdk8s-plus-33construct; - generated/typed
cdk8sApiObjectfor an API without a cdk8s-plus construct; - focused Timonel typed abstraction;
- stop the migration for that feature if the installed Timonel version has no typed path.
Do not make a migration appear complete by casting a ValuesRef, weakening a
resource body to any, or moving raw Kubernetes YAML into a helper template.
Before converting code, inventory all chart behavior that must survive the migration:
-
Chart.yamlmetadata:version,appVersion,type,kubeVersion, maintainers, sources, dependencies, aliases, conditions, tags, and import values; - values files and
values.schema.json; - Kubernetes resources and CRDs;
- hooks and hook weights/delete policies;
- RBAC, service accounts, and workload identity annotations;
-
_helpers.tplnamed templates; - parent/child chart relationships and external dependencies;
- vendored dependencies under
charts/; - non-template files accessed through
.Files; - map iteration, dynamic keys,
index,hasKey,range, andwithusage; - release/chart/capability built-ins;
- every values combination used by CI, production, and upgrades.
Keep a rendered baseline from the existing chart for representative value sets. That becomes the behavioral oracle for the migration.
Use Rutter.getChart() as the construct scope and prefer cdk8s-plus resources:
import * as kplus from "cdk8s-plus-33";
import { Rutter } from "timonel";
const chart = new Rutter({
meta: {
name: "payments",
version: "1.0.0",
},
});
const account = new kplus.ServiceAccount(chart.getChart(), "PaymentsAccount", {
metadata: { name: "payments" },
});
const deployment = new kplus.Deployment(chart.getChart(), "Payments", {
metadata: { name: "payments" },
containers: [
{
name: "payments",
image: "example/payments:1.0.0",
portNumber: 8080,
},
],
serviceAccount: account,
});Keep normal Kubernetes structure in constructs. Values and Helm expressions should only enter at properties for which your Timonel version provides a supported typed binding.
valuesRef<T>() provides typed references to .Values, but a Helm reference is
not the same thing as a JavaScript primitive. Whether it can be assigned
directly depends on the destination API.
Compatibility matrix:
-
Timonel string:
HelmValueRef<string>at a Helm-aware serializer boundary. - Timonel number/boolean: typed ValuesRef where the installed version supports it.
- Native scalar: use the scalar-binding API; never cast the reference.
- Nested object: keep typed nested fields; do not replace it with a raw manifest.
-
Array: use
range()and its typed scoped item. -
String-keyed map: use
rangeEntries()/index()when the version supports them.
For native cdk8s/cdk8s-plus scalar properties, use the explicit Timonel scalar-binding API available in your version. Stable releases that predate that binding cannot safely accept a ValuesRef there. For string-keyed maps, older stable versions may likewise require waiting for typed map operations rather than using casts.
Example values model:
interface Values {
replicaCount: number;
feature: {
enabled: boolean;
};
image: {
repository: string;
tag: string;
};
env: Record<string, string>;
}
const v = valuesRef<Values>();
v.image.tag.quote();
v.feature.enabled.not();
v.env.hasKey("DATABASE_URL");If TypeScript rejects a ValuesRef assignment to a native number, boolean, or
string property, treat that as a real unsupported boundary in that Timonel
version. Do not silence it with a cast.
For versions that include typed resource conditions, keep the Kubernetes resource typed and apply the Helm condition at the Timonel synthesis boundary:
const account = new kplus.ServiceAccount(chart.getChart(), "RestartAccount", {
metadata: { name: "restart-manager" },
});
chart.when(v.restart.enabled, account);Composed conditions remain typed:
chart.when(v.restart.enabled.or(v.restart.forced.not()), account);If the installed release predates Rutter.when(), there is no strict typed-only
equivalent for wrapping an arbitrary construct in {{ if ... }}. Upgrade to a
release containing resource conditions instead of falling back to raw template
YAML.
Model the hook resource itself as a typed construct, then attach Helm hook metadata through the supported lifecycle/hook API. Keep service accounts, Roles, ClusterRoles, RoleBindings, and ClusterRoleBindings as normal typed Kubernetes resources.
Typical migration order:
- create the ServiceAccount;
- create the Role/ClusterRole;
- bind the account;
- create the Job or other hook workload;
- apply hook event, weight, and delete policy;
- render with upgrade/install values and compare against the original hook manifests.
See Helm Hooks and Lifecycle for the supported hook API and examples.
Prefer generated cdk8s imports when a CRD schema is available. Generated types
give the strongest compile-time contract and attach directly to
Rutter.getChart().
For extension APIs without a generated construct, use Timonel's typed custom-resource abstraction when your installed version provides it:
interface ServiceMonitorBody {
spec: {
selector: {
matchLabels: Record<string, string>;
};
endpoints: Array<{
port: string;
interval: string;
}>;
};
}
new TypedCustomResource<ServiceMonitorBody>(chart.getChart(), "ApiMonitor", {
apiVersion: "monitoring.coreos.com/v1",
kind: "ServiceMonitor",
metadata: { name: "api-monitor" },
body: {
spec: {
selector: { matchLabels: { app: "api" } },
endpoints: [{ port: "metrics", interval: "30s" }],
},
},
});apiVersion, kind, and metadata belong to the resource wrapper and must not
be redefined in the body.
If your installed stable release does not contain TypedCustomResource, use a
generated cdk8s API class. If neither exists, strict typed parity is not
available yet; do not disguise an arbitrary object manifest as a typed
migration.
Many production charts iterate environment-variable or secret maps:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
In versions with typed map operations:
const env = v.env.rangeEntries((key, value) => ({
name: key,
value: value.quote(),
}));Dynamic lookup stays typed:
const selected = v.env.index(v.selectedEnvKey);
const exists = v.env.hasKey(v.selectedEnvKey);Timonel anchors values-map lookups to Helm's root context so they remain correct
inside nested range or with scopes. Nested map ranges use distinct internal
variables to avoid shadowing captured outer entries.
Older releases without these operations cannot provide strict typed parity for arbitrary dynamic map access.
Separate three cases:
Use a child Rutter and an UmbrellaRutter entry.
For Timonel versions with dependency-only subcharts, declare the dependency
metadata without creating a fake child Rutter:
{
name: "ingress-nginx",
version: "4.15.1",
repository: "https://kubernetes.github.io/ingress-nginx",
condition: "ingress-nginx.enabled",
}Point the dependency at an existing chart directory using the vendored-source API available in the same feature set. Timonel copies the chart without regenerating it and rejects symlinks or source / destination overlap.
Do not create fake Kubernetes resources merely to satisfy an umbrella API.
Stable versions that require a Rutter for every dependency do not yet offer
strict typed parity for dependency-only subcharts.
Helm .Files reads packaged chart files, not Kubernetes manifests. Do not
encode these files as ConfigMaps just to make Timonel emit them unless that
changes the chart intentionally.
In versions with chart-file assets, write each chart-relative file through the dedicated chart-file API. Destinations must stay inside the chart and must not collide with generated files.
Examples include:
files/config/default.json
files/dashboards/api.json
LICENSES/component.txt
If your stable Timonel version predates chart-file assets, arbitrary .Files
content is a real packaging gap. Upgrade to a release containing that feature
instead of adding raw manifest APIs.
Use type: library plus HelmChartWriter.helpersTpl for shared named
templates. Keep helpers for naming, labels, annotations, and reusable Helm
fragments; do not hide complete Kubernetes resource YAML inside helpers.
See Type-Safe Helm Helpers for createHelper(),
getDefaultHelpers(), helper aliasing, and library-chart validation.
Do not migrate only name and version. Preserve every metadata field required
by the original chart, especially:
appVersion
type
kubeVersion
keywords
home
sources
maintainers
icon
dependencies
Use the full Rutter chart metadata surface in versions that expose it. Older
stable releases may require HelmChartWriter for metadata that Rutter does
not yet expose. If the migration standard requires one typed Rutter path,
upgrade rather than silently dropping metadata.
Treat the existing values contract as public API:
- preserve key names, including reserved-looking keys such as
releaseordefault; - use
v.at("key")for ValuesRef API name collisions; - preserve default values and environment-specific values;
- preserve
values.schema.jsonvalidation; - render with the same realistic values used by production and CI.
Do not rename values solely to make migration code easier unless you intentionally accept a chart breaking change.
A typed compile is necessary but not sufficient. Validate both TypeScript and Helm behavior for every migrated chart.
pnpm typecheck
pnpm buildFor a Timonel contribution, also run the repository's pnpm ci:check and
consumer declaration tests.
helm lint ./dist/payments
helm template payments ./dist/payments \
--values ./test-values/production.yamlRun additional templates for meaningful branches:
feature enabled / disabled
autoscaling enabled / disabled
install / upgrade hooks
empty / populated maps
optional child chart enabled / disabled
Compare the rendered output with the original chart by Kubernetes identity
(apiVersion, kind, namespace, name), then compare meaningful fields rather
than relying only on text ordering.
At minimum verify:
- resource count and identity;
- selectors/labels/annotations;
- RBAC subjects and role rules;
- workload images, ports, probes, volumes, and security context;
- hook annotations and weights;
- conditions and optional resources;
- chart dependencies and packaged files;
- values schema behavior.
Before declaring a chart migrated without manifest fallbacks, all answers should be yes:
- Every Kubernetes resource has a typed cdk8s/cdk8s-plus/Timonel representation.
- No
addManifest()oraddTemplateManifest()remains. - No
anyor double-cast is used to force Helm references into primitive properties. - Dynamic values maps use a supported typed operation.
- Custom resources use generated types or a typed extension construct.
- External/vendored dependencies are represented without fake Rutters.
-
.Filesinputs are packaged through a supported chart-file API. - Full
Chart.yamlmetadata is preserved. - Hooks and RBAC render equivalently.
-
pnpm typecheckand build checks pass. -
helm lintpasses. -
helm templatepasses for representative production values. - Rendered manifests have been compared with the pre-migration baseline.
Timonel evolves these typed boundaries incrementally. A feature may exist on
main or in a canary before it reaches the stable npm tag. Always verify the
API in the exact version your chart consumes.
If the installed release lacks a required typed capability, the strict migration is blocked on that capability. The correct response is to upgrade when the supporting release is available or defer that part of the migration—not to hide the gap with casts or raw templates.
Timonel documentation · Repository · npm · Issues