ErrLookup › Background articles › "Wrong argument type", "must be a string", "expected Array or Prism::Scope": TypeError and ArgumentError when a library receives a value of the wrong type
"Wrong argument type", "must be a string", "expected Array or Prism::Scope": TypeError and ArgumentError when a library receives a value of the wrong type
"Wrong argument type" and its many variants ("must be a string", "is not an Integer", "expected Symbol", "Invalid schema") are the TypeError/ArgumentError family that fires when code hands a library a value whose type it never accepts — a string where an integer belongs, an object where a string is required, an array where a single id is expected. Developers meet it at API boundaries: configuration values parsed from JSON/YAML/ENV, deserialized data, framework callbacks, and DSL options where a bare name silently becomes the wrong kind of object. This article explains the shared mechanism across 28 libraries, the most common triggers, and the boundary-validation habits that prevent the whole family.
Distilled from 97 documented records across 28 repositories.
Background
This family sits at the trust boundary between caller and library: a public method declares (explicitly via a type hint, or implicitly via a guard clause) what types it accepts, and the first thing it does with a wrong-typed argument is refuse it. PHP throws TypeError or InvalidArgumentException (yii2's Security::compareString and FileHelper's pattern parser, doctrine/orm's loadMetadataForClass); Ruby raises TypeError or ArgumentError (Prism's FFI backend, concurrent-ruby's atomics, Linguist's Repository); Python raises TypeError (polars' struct indexing); JavaScript/TypeScript and MCP servers throw TypeError on uncoerced JSON arguments (Deno's PerformanceObserver, chroma's rank expressions, ruflo's memory tools). The failure is immediate by design: libraries such as Prism check before any parsing starts, and Puppet's PAL evaluate_string checks at the API boundary precisely so the error does not surface deep inside the parser with a confusing message.
From the caller's side the error almost always means an upstream type drift, not a library bug. The classic sources are configuration and serialized data: JSON and YAML give you strings and numbers where the code assumes a string (concurrent-ruby's Semaphore.new(params['permits']), faker's subscriber_number(length: '4'), Prism's scopes: ["foo"]), and ENV values are always strings (ENV['EXT_LEN'].to_i). A second cluster is object-vs-value confusion in Ruby: passing a Rugged::Commit where the oid String is expected (Linguist), a Pathname where a String is expected (Prism.parse, Bundler::Digest.sha1), or an ActiveRecord array where a single id is required (GitLab's by_group_and_descendants, which is single-id by design because of its covering index). A third cluster is DSL accidentally-wrong-arity: factory_bot's bare `factory: user` (missing colon) dispatches through method_missing and stores a Declaration object where a symbol was expected — the error surfaces only at first build or lint, not at definition time.
The family varies in strictness and in when it fires. Some libraries coerce nothing at all: chroma rejects even numeric strings like "5" in rank arithmetic, and concurrent-ruby rejects whole-number Floats like 3.0. Others coerce selectively or silently degrade: Deno's PerformanceObserver throws on a non-array entryTypes but silently drops unsupported type names, making observe() a no-op. Some checks are warnings today and errors tomorrow — Capybara's locator-type mismatch warns with the caller's backtrace and explicitly says it will raise in a future version. Timing also varies: Prism's guards run before any work starts, yii2's parseExcludePattern is normally shielded by is_string() guards upstream (so hitting it means a subclass bypassed them), and factory_bot defers the check until the first build/create/lint. A few messages are even slightly misleading — faker says lengths must be "lesser than 10" but the code allows exactly 10 — so read the code, not just the message, when the boundary seems off by one.
Common causes
- Serialized config and request data with drifted types. JSON, YAML, and ENV values reach a strict API as strings, numbers, or arrays instead of the expected type. Examples: Semaphore.new(params['permits']) with a string, Prism scopes: ["foo"] with strings instead of symbol arrays, MCP memory_list with namespace: 5, Deno observe({ entryTypes: "mark" }) as a bare string instead of an array.
- Object passed where a scalar or string is required. Ruby callers hand a Rugged::Commit or Reference where Linguist wants the oid String, a Pathname where Prism.parse or Bundler::Digest.sha1 wants a String, or caller.first (a String) where Prism.find wants a Thread::Backtrace::Location. The fix is resolving to the plain value at the call site.
- Bare identifier instead of symbol in a DSL options hash. In factory_bot, `author factory: user` (no colon) makes method_missing wrap the name in a Declaration::Implicit object, which the association guard rejects at first build with a "Did you mean? 'factory: :user'" hint. The same typo pattern hits both the factory target and attribute overrides.
- Assignable non-callable or wrong-class object where a specific type is demanded. Puppet option hooks and defaults require actual Procs — Symbols and even Method objects are rejected until converted with .to_proc; redis-rb's Index.create requires a Schema instance, not a field array or hash; doctrine's DatabaseDriver requires the ORM ClassMetadata, not the shared persistence interface; Puppet's TLS layer requires a Puppet::SSL::Verifier instance, never a raw SSLContext.
- Collection passed where a single value is expected (or vice versa). GitLab's by_group_and_descendants raises 'only a single id is supported' for any Enumerable because its SQL form is single-id by design; Prism's scopes option wants a list of scopes and forwarding a single Scope fails; PhpSpreadsheet's duplicateConditionalStyle rejects Style objects and raw arrays mixed into its Conditional[] list.
- Accidental nil or wrong-key lookup feeding a strict comparison. yii2's Security::compareString throws when the trusted side is null because a config key was mistyped or a DB column is NULL — the gettype() suffix in the message names the offending type. Omitting the parameter (with defaults) is often valid while explicitly passing nil is not, as in faker's amount keyword.
- Subclass or reflection bypassing the library's own guards. yii2's pattern parser is normally reached only through is_string-guarded paths, so hitting its raw type assertion means a custom subclass or reflection-based test called it directly. Puppet's Option setters are safe through the DSL (before_action { ... }) and only fail when manipulated directly.
What usually fixes it
- Normalize at the boundary: convert once where untyped data enters your code — String(...)/to_s, Kernel#Integer (strict, raises on garbage), int()/str() in Python — rather than scattering coercions at every library call site.
- Type your call sites and payloads: declare pattern lists as string[], type MCP request payloads as { namespace?: string }, hint against the ORM-specific ClassMetadata instead of the shared interface, and let PHPStan/IDE/schema validation catch the drift statically.
- Prefer the library's DSL and factory constructors over direct internals: Schema.build, default_to { ... }, before_action { ... }, FactoryBot.lint in CI — these enforce correct types for you and surface errors earlier than first runtime use.
- Branch and unwrap per contract instead of forwarding generically: coerce to string for #document filters in chroma, pass commit.oid/rev_parse_oid Strings to Linguist, unwrap custom callable wrappers to the underlying Proc/Method for Prism.find, and loop per id for single-id scopes.
- Read the message for the reported type and fix the producer, not the call: the class/type named in the error (gettype() in yii2, class_name in Puppet, qualified_type_name in polars) points back to the config key, YAML path, or deserialized field that actually drifted.
Go deeper
- SSL/TLS and certificate errors — how TLS handshakes and certificate validation fail.
Documented occurrences
- Exclude/include pattern must be a string. (yiisoft/yii2)
- Association '#{name}' received an invalid factory argument. Did you mean? 'factory: :#{factory_name.name}' (thoughtbot/factory_bot)
- namespace must be a non-empty string (ruvnet/ruflo)
- commit_oid must be a commit SHA1 (github-linguist/linguist)
- TypeError (ruby/ruby)
- Association '#{name}' received an invalid attribute override. Did you mean? '#{attribute}: :#{value.name}' (thoughtbot/factory_bot)
- Failed to execute 'observe' on 'PerformanceObserver': 'entryTypes' must be an array. (denoland/deno)
- Locator #{locator.class}:#{locator.inspect} for selector #{name.inspect} must #{locator_description}. This will raise an error in a future version of Capybara. Called from: #{Capybara::Helpers.filter_backtrace(caller)} (teamcapybara/capybara)
- before action hook for %{name} is a %{class_name}, not a proc (puppetlabs/puppet)
- Rank input must be a RankExpression, number, or plain object (chroma-core/chroma)
- wrong argument type #{scope.class.inspect} (expected Array or Prism::Scope) (ruby/ruby)
- Invalid schema (redis/redis-rb)
- default value for %{name} is a %{class_name}, not a proc (puppetlabs/puppet)
- can't convert #{string.class.inspect} into String (ruby/ruby)
- only a single id is supported (gitlabhq/gitlabhq)
- invalid amount (faker-ruby/faker)
- Expected expected value to be a string, (yiisoft/yii2)
- K.DOCUMENT.contains requires a string value (chroma-core/chroma)
- wrong argument type #{value.class} (expected Symbol) (ruby/ruby)
- Argument #2 passed to %s() must be an instance of %s, %s given. (doctrine/orm)
…and 77 more across the corpus — use search.
Honest provenance: generated on 2026-08-27 from AI-assisted analysis of the linked records. See how records are made.