{"record":{"id":"533da41106351b82","repo":"mifi/lossless-cut","slug":"property-must-be-an-object-of-strings","errorCode":null,"errorMessage":"\"{{property}}\" must be an object of strings","messagePattern":"\"(.+?)\\}\" must be an object of strings","errorType":"exception","errorClass":"UserFacingError","httpStatus":null,"severity":"warning","filePath":"src/renderer/src/hooks/useSegments.tsx","lineNumber":832,"sourceCode":"    async function mutateSegment(seg: StateSegment, index: number, expr: string) {\n      const response = (await safeishEval(expr, { segment: getScopeSegment(seg, index) }));\n      invariant(typeof response === 'object' && response != null, i18n.t('The expression must return an object'));\n      const ret: Partial<Pick<StateSegment, 'name' | 'start' | 'end' | 'tags'>> = {};\n      if ('label' in response) {\n        if (typeof response.label !== 'string') throw new UserFacingError(i18n.t('\"{{property}}\" must be a string', { property: 'label' }));\n        ret.name = response.label;\n      }\n      if ('start' in response) {\n        if (typeof response.start !== 'number') throw new UserFacingError(i18n.t('\"{{property}}\" must be a number', { property: 'start' }));\n        ret.start = response.start;\n      }\n      if ('end' in response) {\n        if (!(typeof response.end === 'number' || response.end === undefined)) throw new UserFacingError(i18n.t('\"{{property}}\" must be a number', { property: 'end' }));\n        ret.end = response.end;\n      }\n      if ('tags' in response) {\n        const tags = segmentTagsSchema.safeParse(response.tags);\n        if (!tags.success) throw new UserFacingError(i18n.t('\"{{property}}\" must be an object of strings', { property: 'tags' }));\n        ret.tags = tags.data;\n      }\n      return ret;\n    }\n\n    const mutateSegments = async (expr: string) => (await pMap(cutSegments, async (seg, index) => ({\n      ...seg,\n      ...(seg.selected && await mutateSegment(seg, index, expr)),\n    }), { concurrency: 5 })).flat();\n\n    const onSubmit = async (value: string) => {\n      try {\n        if (value.trim().length === 0) return { error: i18n.t('Please enter a JavaScript expression.') };\n        const mutated = await mutateSegments(value);\n\n        safeSetCutSegments(mutated, fileDuration);\n\n        return undefined;","sourceCodeStart":814,"sourceCodeEnd":850,"githubUrl":"https://github.com/mifi/lossless-cut/blob/3b9a59c288bf6e11076b583c932cfa48ddab3b02/src/renderer/src/hooks/useSegments.tsx#L814-L850","documentation":"Thrown by mutateSegment (useSegments.tsx:832) when `response.tags` fails to parse against `segmentTagsSchema`, defined as `z.record(z.string(), z.string())` in types.ts:23 — i.e. tags must be a plain object whose keys and values are all strings. Arrays, primitives, null, or objects containing non-string values (numbers, booleans, nested objects) all fail safeParse and raise this UserFacingError. Surfaced as 'Expression failed: \"tags\" must be an object of strings'.","triggerScenarios":"Returning `{ tags: ['a','b'] }` (array, not record), `{ tags: 'tag' }` (string), `{ tags: { count: segment.index } }` (numeric value), `{ tags: { flag: true } }` (boolean value), `{ tags: { nested: { a: 'b' } } }` (nested object), or `{ tags: null }`.","commonSituations":"Users store a numeric counter or boolean flag as a tag value without coercing to string. Migrating from an array-based tag mental model. Spreading segment.tags then adding a numeric property: `{ tags: { ...segment.tags, count: segment.index } }` — the spread is fine but the added number trips the schema.","solutions":["Coerce every non-string value with String(): `{ tags: { count: String(segment.index) } }`","Use string literal values: `{ tags: { even: 'true' } }` (string 'true', not boolean)","Spread existing tags and only add string values: `{ tags: { ...segment.tags, newKey: 'val' } }`","If passing through segment.tags unchanged, omit the `tags` key from the returned object instead of re-assigning it"],"exampleFix":"// before (numeric tag value fails z.record(string,string))\n{ tags: { count: segment.index } }\n\n// after\n{ tags: { count: String(segment.index) } }","handlingStrategy":"validation","validationCode":"import { segmentTagsSchema } from '../types';\n\nfunction validateMutateResponse(response: unknown): string | null {\n  if (typeof response !== 'object' || response === null)\n    return 'The expression must return an object';\n  const r = response as Record<string, unknown>;\n  if ('tags' in r && !segmentTagsSchema.safeParse(r.tags).success)\n    return '\"tags\" must be an object of strings (Record<string, string>)';\n  return null;\n}","typeGuard":"import { segmentTagsSchema, type SegmentTags } from '../types';\n\nfunction hasValidTags(r: object): r is { tags: SegmentTags } & object {\n  if (!('tags' in r)) return false;\n  return segmentTagsSchema.safeParse((r as { tags?: unknown }).tags).success;\n}","tryCatchPattern":"// safeParse already runs before the throw (useSegments.tsx:831).\n// To give users actionable detail, surface zod's issue in the catch:\nimport { ZodError } from 'zod';\n// inside mutateSegment, replace the throw with:\nif (!tags.success)\n  throw new UserFacingError(\n    i18n.t('\"{{property}}\" must be an object of strings ({{detail}})',\n      { property: 'tags', detail: (tags.error as ZodError).issues[0]?.message ?? '' }),\n  );","preventionTips":["Coerce every tag value with String() — never store numbers or booleans directly","Remember tags is a Record (key→string object), not an array","When spreading segment.tags, only add string-valued keys","If you only want to preserve existing tags, omit the `tags` key from the result"],"tags":["validation","zod","expression-eval","tags","user-input","i18n"],"backgroundTag":null,"analyzedSha":"3b9a59c288bf6e11076b583c932cfa48ddab3b02","analyzedAt":"2026-08-12T20:54:25.651Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}